Repository: Qiskit/qiskit

Issue:
align_center() DSL directive
This is a follow up to #4174 to add another directive for aligning the pulses that appear within a block at their center. I think this is not that different from the other alignments (left, right). You would just find the longest pulse within that block and reserve a slot of that size. Mark the center point as `c`. Then all other pulses will last from `c-duration/2 - c+duration/2`.

Example:

```
d0 -----------------------[20 dt]------------------------
d1 -----[                 180 dt                   ]-----
d2 ------------[          100 dt             ]-----------
```


Discussion:
What is unclear to me is the desired behavior when there are multiple pulses on a given channel in a block? Do we wish to squeeze them together in the center like so?

```
d0 -------------------------[20 dt]----------------------------------------
d1  -----[      40dt       ][   100 dt            ][     40dt       ]------
d2 ------------------[          100 dt             ]-----------------------
```
If so this should be straightforward to implement.
Yeah I think that would be a fine behavior. 

> On May 14, 2020, at 6:49 PM, Thomas Alexander <notifications@github.com> wrote:
> 
> What is unclear to me is the desired behavior when there are multiple pulses on a given channel in a block? Do we wish to squeeze them together in the center like so?
> 
> d0 -----------------------[20 dt]------------------------
> d1 -----[      40dt       ][   100 dt            ][     40dt       ]-----
> d2 ------------[          100 dt             ]--------------------
> If so this should be straightforward to implement.
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub, or unsubscribe.

Ok, excellent we will tackle this in a follow-up feature PR.

Resolving patch:
diff --git a/qiskit/pulse/__init__.py b/qiskit/pulse/__init__.py
--- a/qiskit/pulse/__init__.py
+++ b/qiskit/pulse/__init__.py
@@ -280,6 +280,8 @@
 .. autosummary::
     :toctree: ../stubs/
 
+    ~qiskit.pulse.builder.align_equispaced
+    ~qiskit.pulse.builder.align_func
     ~qiskit.pulse.builder.align_left
     ~qiskit.pulse.builder.align_right
     ~qiskit.pulse.builder.align_sequential
@@ -412,6 +414,8 @@
     drive_channel,
     measure_channel,
     # Contexts.
+    align_equispaced,
+    align_func,
     align_left,
     align_right,
     align_sequential,
diff --git a/qiskit/pulse/builder.py b/qiskit/pulse/builder.py
--- a/qiskit/pulse/builder.py
+++ b/qiskit/pulse/builder.py
@@ -896,6 +896,99 @@ def align_sequential() -> ContextManager[None]:
     """
 
 
+# pylint: disable=unused-argument
+@_transform_context(transforms.align_equispaced)
+def align_equispaced(duration: int) -> ContextManager[None]:
+    """Equispaced alignment pulse scheduling context.
+
+    Pulse instructions within this context are scheduled with the same interval spacing such that
+    the total length of the context block is ``duration``.
+    If the total free ``duration`` cannot be evenly divided by the number of instructions
+    within the context, the modulo is split and then prepended and appended to
+    the returned schedule. Delay instructions are automatically inserted in between pulses.
+
+    This context is convenient to write a schedule for periodical dynamic decoupling or
+    the Hahn echo sequence.
+
+    Examples:
+
+    .. jupyter-execute::
+
+        from qiskit import pulse
+
+        d0 = pulse.DriveChannel(0)
+        x90 = pulse.Gaussian(10, 0.1, 3)
+        x180 = pulse.Gaussian(10, 0.2, 3)
+
+        with pulse.build() as hahn_echo:
+            with pulse.align_equispaced(duration=100):
+                pulse.play(x90, d0)
+                pulse.play(x180, d0)
+                pulse.play(x90, d0)
+
+        hahn_echo.draw()
+
+    Args:
+        duration: Duration of this context. This should be larger than the schedule duration.
+
+    Notes:
+        The scheduling is performed for sub-schedules within the context rather than
+        channel-wise. If you want to apply the equispaced context for each channel,
+        you should use the context independently for channels.
+    """
+
+
+# pylint: disable=unused-argument
+@_transform_context(transforms.align_func)
+def align_func(duration: int,
+               func: Callable[[int], float]) -> ContextManager[None]:
+    """Callback defined alignment pulse scheduling context.
+
+    Pulse instructions within this context are scheduled at the location specified by
+    arbitrary callback function `position` that takes integer index and returns
+    the associated fractional location witin [0, 1].
+    Delay instruction is automatically inserted in between pulses.
+
+    This context may be convenient to write a schedule of arbitrary dynamical decoupling
+    sequences such as Uhrig dynamical decoupling.
+
+    Examples:
+
+    .. jupyter-execute::
+
+        import numpy as np
+        from qiskit import pulse
+
+        d0 = pulse.DriveChannel(0)
+        x90 = pulse.Gaussian(10, 0.1, 3)
+        x180 = pulse.Gaussian(10, 0.2, 3)
+
+        def udd10_pos(j):
+            return np.sin(np.pi*j/(2*10 + 2))**2
+
+        with pulse.build() as udd_sched:
+            pulse.play(x90, d0)
+            with pulse.align_func(duration=300, func=udd10_pos):
+                for _ in range(10):
+                    pulse.play(x180, d0)
+            pulse.play(x90, d0)
+
+        udd_sched.draw()
+
+    Args:
+        duration: Duration of context. This should be larger than the schedule duration.
+        func: A function that takes an index of sub-schedule and returns the
+            fractional coordinate of of that sub-schedule.
+            The returned value should be defined within [0, 1].
+            The pulse index starts from 1.
+
+    Notes:
+        The scheduling is performed for sub-schedules within the context rather than
+        channel-wise. If you want to apply the numerical context for each channel,
+        you need to apply the context independently to channels.
+    """
+
+
 def _align(alignment: str = 'left') -> ContextManager[None]:
     """General alignment context. Used by the :class:`_Builder` to choose the
     default alignment policy.
diff --git a/qiskit/pulse/transforms.py b/qiskit/pulse/transforms.py
--- a/qiskit/pulse/transforms.py
+++ b/qiskit/pulse/transforms.py
@@ -15,13 +15,15 @@
 """
 import warnings
 from collections import defaultdict
+from typing import Callable
 from typing import List, Optional, Iterable
 
 import numpy as np
 
 from qiskit.pulse import channels as chans, exceptions, instructions, interfaces
-from qiskit.pulse.instructions import directives
+from qiskit.pulse.exceptions import PulseError
 from qiskit.pulse.instruction_schedule_map import InstructionScheduleMap
+from qiskit.pulse.instructions import directives
 from qiskit.pulse.schedule import Schedule
 
 
@@ -254,8 +256,11 @@ def pad(schedule: Schedule,
             continue
 
         curr_time = 0
+        # Use the copy of timeslots. When a delay is inserted before the current interval,
+        # current timeslot is pointed twice and the program crashes with the wrong pointer index.
+        timeslots = schedule.timeslots[channel].copy()
         # TODO: Replace with method of getting instructions on a channel
-        for interval in schedule.timeslots[channel]:
+        for interval in timeslots:
             if curr_time >= until:
                 break
             if interval[0] != curr_time:
@@ -428,6 +433,85 @@ def align_sequential(schedule: Schedule) -> Schedule:
     return aligned
 
 
+def align_equispaced(schedule: Schedule,
+                     duration: int) -> Schedule:
+    """Schedule a list of pulse instructions with equivalent interval.
+
+    Args:
+        schedule: Input schedule of which top-level ``child`` nodes will be
+            reschedulued.
+        duration: Duration of context. This should be larger than the schedule duration.
+
+    Returns:
+        New schedule with input `schedule`` child schedules and instructions
+        aligned with equivalent interval.
+
+    Notes:
+        This context is convenient for writing PDD or Hahn echo sequence for example.
+    """
+    if duration and duration < schedule.duration:
+        return schedule
+    else:
+        total_delay = duration - schedule.duration
+
+    if len(schedule._children) > 1:
+        # Calculate the interval in between sub-schedules.
+        # If the duration cannot be divided by the number of sub-schedules,
+        # the modulo is appended and prepended to the input schedule.
+        interval, mod = np.divmod(total_delay, len(schedule._children) - 1)
+    else:
+        interval = 0
+        mod = total_delay
+
+    # Calculate pre schedule delay
+    delay, mod = np.divmod(mod, 2)
+
+    aligned = Schedule()
+    # Insert sub-schedules with interval
+    _t0 = int(aligned.stop_time + delay + mod)
+    for _, child in schedule._children:
+        aligned.insert(_t0, child, inplace=True)
+        _t0 = int(aligned.stop_time + interval)
+
+    return pad(aligned, aligned.channels, until=duration, inplace=True)
+
+
+def align_func(schedule: Schedule,
+               duration: int,
+               func: Callable[[int], float]) -> Schedule:
+    """Schedule a list of pulse instructions with schedule position defined by the
+    numerical expression.
+
+    Args:
+        schedule: Input schedule of which top-level ``child`` nodes will be
+            reschedulued.
+        duration: Duration of context. This should be larger than the schedule duration.
+        func: A function that takes an index of sub-schedule and returns the
+            fractional coordinate of of that sub-schedule.
+            The returned value should be defined within [0, 1].
+            The pulse index starts from 1.
+
+    Returns:
+        New schedule with input `schedule`` child schedules and instructions
+        aligned with equivalent interval.
+
+    Notes:
+        This context is convenient for writing UDD sequence for example.
+    """
+    if duration < schedule.duration:
+        return schedule
+
+    aligned = Schedule()
+    for ind, (_, child) in enumerate(schedule._children):
+        _t_center = duration * func(ind + 1)
+        _t0 = int(_t_center - 0.5 * child.duration)
+        if _t0 < 0 or _t0 > duration:
+            PulseError('Invalid schedule position t=%d is specified at index=%d' % (_t0, ind))
+        aligned.insert(_t0, child, inplace=True)
+
+    return pad(aligned, aligned.channels, until=duration, inplace=True)
+
+
 def flatten(schedule: Schedule) -> Schedule:
     """Flatten any called nodes into a Schedule tree with no nested children."""
     return schedule.flatten()


Test patch:
diff --git a/test/python/pulse/test_transforms.py b/test/python/pulse/test_transforms.py
--- a/test/python/pulse/test_transforms.py
+++ b/test/python/pulse/test_transforms.py
@@ -336,6 +336,18 @@ def test_padding_less_than_sched_duration(self):
         ref_sched = (sched | pulse.Delay(5, DriveChannel(0)).shift(10))
         self.assertEqual(transforms.pad(sched, until=15), ref_sched)
 
+    def test_padding_prepended_delay(self):
+        """Test that there is delay before the first instruction."""
+        delay = 10
+        sched = (Delay(delay, DriveChannel(0)).shift(10) +
+                 Delay(delay, DriveChannel(0)))
+
+        ref_sched = (Delay(delay, DriveChannel(0)) +
+                     Delay(delay, DriveChannel(0)) +
+                     Delay(delay, DriveChannel(0)))
+
+        self.assertEqual(transforms.pad(sched, until=30, inplace=True), ref_sched)
+
 
 def get_pulse_ids(schedules: List[Schedule]) -> Set[int]:
     """Returns ids of pulses used in Schedules."""
@@ -642,6 +654,93 @@ def test_align_right_with_barrier(self):
         self.assertEqual(schedule, reference)
 
 
+class TestAlignEquispaced(QiskitTestCase):
+    """Test equispaced alignment transform."""
+
+    def test_equispaced_with_short_duration(self):
+        """Test equispaced context with duration shorter than the schedule duration."""
+        d0 = pulse.DriveChannel(0)
+
+        sched = pulse.Schedule()
+        for _ in range(3):
+            sched.append(Delay(10, d0), inplace=True)
+
+        sched = transforms.align_equispaced(sched, duration=20)
+
+        reference = pulse.Schedule()
+        reference.insert(0, Delay(10, d0), inplace=True)
+        reference.insert(10, Delay(10, d0), inplace=True)
+        reference.insert(20, Delay(10, d0), inplace=True)
+
+        self.assertEqual(sched, reference)
+
+    def test_equispaced_with_longer_duration(self):
+        """Test equispaced context with duration longer than the schedule duration."""
+        d0 = pulse.DriveChannel(0)
+
+        sched = pulse.Schedule()
+        for _ in range(3):
+            sched.append(Delay(10, d0), inplace=True)
+
+        sched = transforms.align_equispaced(sched, duration=50)
+
+        reference = pulse.Schedule()
+        reference.insert(0, Delay(10, d0), inplace=True)
+        reference.insert(10, Delay(10, d0), inplace=True)
+        reference.insert(20, Delay(10, d0), inplace=True)
+        reference.insert(30, Delay(10, d0), inplace=True)
+        reference.insert(40, Delay(10, d0), inplace=True)
+
+        self.assertEqual(sched, reference)
+
+
+class TestAlignFunc(QiskitTestCase):
+    """Test callback alignment transform."""
+
+    @staticmethod
+    def _position(ind):
+        """Returns 0.25, 0.5, 0.75 for ind = 1, 2, 3."""
+        return ind / (3 + 1)
+
+    def test_numerical_with_short_duration(self):
+        """Test numerical alignment context with duration shorter than the schedule duration."""
+        d0 = pulse.DriveChannel(0)
+
+        sched = pulse.Schedule()
+        for _ in range(3):
+            sched.append(Delay(10, d0), inplace=True)
+
+        sched = transforms.align_func(sched, duration=20, func=self._position)
+
+        reference = pulse.Schedule()
+        reference.insert(0, Delay(10, d0), inplace=True)
+        reference.insert(10, Delay(10, d0), inplace=True)
+        reference.insert(20, Delay(10, d0), inplace=True)
+
+        self.assertEqual(sched, reference)
+
+    def test_numerical_with_longer_duration(self):
+        """Test numerical alignment context with duration longer than the schedule duration."""
+        d0 = pulse.DriveChannel(0)
+
+        sched = pulse.Schedule()
+        for _ in range(3):
+            sched.append(Delay(10, d0), inplace=True)
+
+        sched = transforms.align_func(sched, duration=80, func=self._position)
+
+        reference = pulse.Schedule()
+        reference.insert(0, Delay(15, d0), inplace=True)
+        reference.insert(15, Delay(10, d0), inplace=True)
+        reference.insert(25, Delay(10, d0), inplace=True)
+        reference.insert(35, Delay(10, d0), inplace=True)
+        reference.insert(45, Delay(10, d0), inplace=True)
+        reference.insert(55, Delay(10, d0), inplace=True)
+        reference.insert(65, Delay(15, d0), inplace=True)
+
+        self.assertEqual(sched, reference)
+
+
 class TestFlatten(QiskitTestCase):
     """Test flattening transform."""