Repository: Qiskit/qiskit

Issue:
Helper function for pulse to auto align measurement
It can be helpful if all schedules in an experiment have measurements that occur at the same physical time after the start of the schedule (particular for level 2 measurement). This would be a helper function that would offset schedules appropriately and add measurements/acquires automatically. Also this would enforce any constraints on minimum wait times for the measurement (e.g. to ensure enough time for the calibration schedules which are added by the hardware). 

<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->

### What is the expected enhancement?





Discussion:
This method should:
1. Search through the schedule in conjunction with `meas_map` and find acquires which do not fill out a `meas_map` instance. In such a case default acquires of equal duration should be inserted on qubits in `meas_map` instance that were not already being measured. 
2. If `cmd_def` is provided as argument all instructions should be shifted so that acquires happen after the longest `u2` gate in the `cmd_def`. This is so that sufficient time is available on devices to peform calibration experiments.
Moving this to 0.9 milestone as it doesn't seem like it'll be done for 0.8.

Resolving patch:
diff --git a/qiskit/pulse/cmd_def.py b/qiskit/pulse/cmd_def.py
--- a/qiskit/pulse/cmd_def.py
+++ b/qiskit/pulse/cmd_def.py
@@ -197,7 +197,7 @@ def cmd_qubits(self, cmd_name: str) -> List[Tuple[int]]:
         if cmd_name in self._cmd_dict:
             return list(sorted(self._cmd_dict[cmd_name].keys()))
 
-        raise PulseError('Command %s does not exist in CmdDef.' % cmd_name)
+        return []
 
     def __repr__(self):
         return repr(self._cmd_dict)
diff --git a/qiskit/pulse/utils.py b/qiskit/pulse/utils.py
new file mode 100644
--- /dev/null
+++ b/qiskit/pulse/utils.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2019.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Pulse utilities.
+"""
+import warnings
+
+from typing import List, Optional
+
+import numpy as np
+
+from .channels import AcquireChannel, MemorySlot
+from .cmd_def import CmdDef
+from .commands import Acquire, AcquireInstruction
+from .exceptions import PulseError
+from .interfaces import ScheduleComponent
+from .schedule import Schedule
+
+
+def align_measures(schedules: List[ScheduleComponent], cmd_def: CmdDef, cal_gate: str = 'u3',
+                   max_calibration_duration: Optional[int] = None,
+                   align_time: Optional[int] = None) -> Schedule:
+    """Return new schedules where measurements occur at the same physical time. Minimum measurement
+    wait time (to allow for calibration pulses) is enforced.
+    This is only defined for schedules that are acquire-less or acquire-final per channel: a
+    schedule with pulses or acquires occuring on a channel which has already had an acquire will
+    throw an error.
+
+    Args:
+        schedules: Collection of schedules to be aligned together
+        cmd_def: Command definition list
+        cal_gate: The name of the gate to inspect for the calibration time
+        max_calibration_duration: If provided, cmd_def and cal_gate will be ignored
+        align_time: If provided, this will be used as final align time.
+    Returns:
+        Schedule
+    Raises:
+        PulseError: if an acquire or pulse is encountered on a channel that has already been part
+                    of an acquire, or
+                    if align_time is negative
+    """
+    if align_time is not None and align_time < 0:
+        raise PulseError("Align time cannot be negative.")
+    if align_time is None:
+        # Need time to allow for calibration pulses to be played for result classification
+        if max_calibration_duration is None:
+            max_calibration_duration = 0
+            for qubits in cmd_def.cmd_qubits(cal_gate):
+                cmd = cmd_def.get(cal_gate, qubits, np.pi, 0, np.pi)
+                max_calibration_duration = max(cmd.duration, max_calibration_duration)
+
+        # Schedule the acquires to be either at the end of the needed calibration time, or when the
+        # last acquire is scheduled, whichever comes later
+        align_time = max_calibration_duration
+        for schedule in schedules:
+            last_acquire = max([time for time, inst in schedule.instructions
+                                if isinstance(inst, AcquireInstruction)])
+            align_time = max(align_time, last_acquire)
+
+    # Shift acquires according to the new scheduled time
+    new_schedules = []
+    for schedule in schedules:
+        new_schedule = Schedule()
+        acquired_channels = set()
+        for time, inst in schedule.instructions:
+            for chan in inst.channels:
+                if chan.index in acquired_channels:
+                    raise PulseError("Pulse encountered on channel {0} after acquire on "
+                                     "same channel.".format(chan.index))
+            if isinstance(inst, AcquireInstruction):
+                if time > align_time:
+                    warnings.warn("You provided an align_time which is scheduling an acquire "
+                                  "sooner than it was scheduled for in the original Schedule.")
+                new_schedule |= inst << align_time
+                acquired_channels.update({a.index for a in inst.acquires})
+            else:
+                new_schedule |= inst << time
+
+        new_schedules.append(new_schedule)
+
+    return new_schedules
+
+
+def add_implicit_acquires(schedule: ScheduleComponent, meas_map: List[List[int]]) -> Schedule:
+    """Return a new schedule with implicit acquires from the measurement mapping replaced by
+    explicit ones.
+
+    Warning:
+        Since new acquires are being added, Memory Slots will be set to match the qubit index. This
+        may overwrite your specification.
+
+    Args:
+        schedule: Schedule to be aligned
+        meas_map: List of lists of qubits that are measured together
+    Returns:
+        Schedule
+    """
+    new_schedule = Schedule(name=schedule.name)
+
+    for time, inst in schedule.instructions:
+        if isinstance(inst, AcquireInstruction):
+            if any([acq.index != mem.index for acq, mem in zip(inst.acquires, inst.mem_slots)]):
+                warnings.warn("One of your acquires was mapped to a memory slot which didn't match"
+                              " the qubit index. I'm relabeling them to match.")
+            cmd = Acquire(inst.duration, inst.command.discriminator, inst.command.kernel)
+            # Get the label of all qubits that are measured with the qubit(s) in this instruction
+            existing_qubits = {chan.index for chan in inst.acquires}
+            all_qubits = []
+            for sublist in meas_map:
+                if existing_qubits.intersection(set(sublist)):
+                    all_qubits.extend(sublist)
+            # Replace the old acquire instruction by a new one explicitly acquiring all qubits in
+            # the measurement group.
+            new_schedule |= AcquireInstruction(
+                cmd,
+                [AcquireChannel(i) for i in all_qubits],
+                [MemorySlot(i) for i in all_qubits]) << time
+        else:
+            new_schedule |= inst << time
+
+    return new_schedule


Test patch:
diff --git a/qiskit/test/mock.py b/qiskit/test/mock.py
--- a/qiskit/test/mock.py
+++ b/qiskit/test/mock.py
@@ -212,7 +212,7 @@ def __init__(self):
             dt=1.3333,
             dtm=10.5,
             rep_times=[100, 250, 500, 1000],
-            meas_map=[[0], [1, 2]],
+            meas_map=[[0, 1]],
             channel_bandwidth=[
                 [-0.2, 0.4], [-0.3, 0.3], [-0.3, 0.3],
                 [-0.02, 0.02], [-0.02, 0.02], [-0.02, 0.02]
@@ -231,10 +231,16 @@ def __init__(self):
             meas_freq_est=[6.5, 6.6],
             buffer=10,
             pulse_library=[PulseLibraryItem(name='test_pulse_1', samples=[0.j, 0.1j]),
-                           PulseLibraryItem(name='test_pulse_2', samples=[0.j, 0.1j, 1j])],
+                           PulseLibraryItem(name='test_pulse_2', samples=[0.j, 0.1j, 1j]),
+                           PulseLibraryItem(name='test_pulse_3',
+                                            samples=[0.j, 0.1j, 1j, 0.5 + 0j])],
             cmd_def=[Command(name='u1', qubits=[0],
                              sequence=[PulseQobjInstruction(name='fc', ch='d0',
                                                             t0=0, phase='-P1*np.pi')]),
+                     Command(name='u3', qubits=[0],
+                             sequence=[PulseQobjInstruction(name='test_pulse_1', ch='d0', t0=0)]),
+                     Command(name='u3', qubits=[1],
+                             sequence=[PulseQobjInstruction(name='test_pulse_3', ch='d1', t0=0)]),
                      Command(name='cx', qubits=[0, 1],
                              sequence=[PulseQobjInstruction(name='test_pulse_1', ch='d0', t0=0),
                                        PulseQobjInstruction(name='test_pulse_2', ch='u0', t0=10),
diff --git a/test/python/pulse/test_utils.py b/test/python/pulse/test_utils.py
new file mode 100644
--- /dev/null
+++ b/test/python/pulse/test_utils.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2019.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Test cases for the pulse utilities."""
+import unittest
+
+import numpy as np
+
+from qiskit import pulse
+from qiskit.pulse.cmd_def import CmdDef
+from qiskit.pulse.commands import AcquireInstruction
+from qiskit.pulse.exceptions import PulseError
+from qiskit.test import QiskitTestCase
+from qiskit.test.mock import FakeOpenPulse2Q
+
+from qiskit.pulse.utils import add_implicit_acquires, align_measures
+
+
+class TestAutoMerge(QiskitTestCase):
+    """Test the helper function which aligns acquires."""
+
+    def setUp(self):
+        self.backend = FakeOpenPulse2Q()
+        self.device = pulse.DeviceSpecification.create_from(self.backend)
+        self.config = self.backend.configuration()
+        self.defaults = self.backend.defaults()
+        self.cmd_def = CmdDef.from_defaults(self.defaults.cmd_def,
+                                            self.defaults.pulse_library)
+        self.short_pulse = pulse.SamplePulse(samples=np.array([0.02739068], dtype=np.complex128),
+                                             name='p0')
+
+    def test_align_measures(self):
+        """Test that one acquire is delayed to match the time of the later acquire."""
+        acquire = pulse.Acquire(5)
+        sched = pulse.Schedule(name='fake_experiment')
+        sched = sched.insert(0, self.short_pulse(self.device.q[0].drive))
+        sched = sched.insert(1, acquire(self.device.q[0], self.device.mem[0]))
+        sched = sched.insert(10, acquire(self.device.q[1], self.device.mem[1]))
+        sched = align_measures([sched], self.cmd_def)[0]
+        for time, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 10)
+        sched = align_measures([sched], self.cmd_def, align_time=20)[0]
+        for time, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 20)
+
+    def test_align_post_u3(self):
+        """Test that acquires are scheduled no sooner than the duration of the longest X gate.
+        """
+        acquire = pulse.Acquire(5)
+        sched = pulse.Schedule(name='fake_experiment')
+        sched = sched.insert(0, self.short_pulse(self.device.q[0].drive))
+        sched = sched.insert(1, acquire(self.device.q[0], self.device.mem[0]))
+        sched = align_measures([sched], self.cmd_def)[0]
+        for time, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 4)
+        sched = align_measures([sched], self.cmd_def, max_calibration_duration=10)[0]
+        for time, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 10)
+
+    def test_error_multi_acquire(self):
+        """Test that an error is raised if multiple acquires occur on the same channel."""
+        acquire = pulse.Acquire(5)
+        sched = pulse.Schedule(name='fake_experiment')
+        sched = sched.insert(0, self.short_pulse(self.device.q[0].drive))
+        sched = sched.insert(4, acquire(self.device.q[0], self.device.mem[0]))
+        sched = sched.insert(10, acquire(self.device.q[0], self.device.mem[0]))
+        with self.assertRaises(PulseError):
+            align_measures([sched], self.cmd_def)
+
+    def test_error_post_acquire_pulse(self):
+        """Test that an error is raised if a pulse occurs on a channel after an acquire."""
+        acquire = pulse.Acquire(5)
+        sched = pulse.Schedule(name='fake_experiment')
+        sched = sched.insert(0, self.short_pulse(self.device.q[0].drive))
+        sched = sched.insert(4, acquire(self.device.q[0], self.device.mem[0]))
+        # No error with separate channel
+        sched = sched.insert(10, self.short_pulse(self.device.q[1].drive))
+        align_measures([sched], self.cmd_def)
+        sched = sched.insert(10, self.short_pulse(self.device.q[0].drive))
+        with self.assertRaises(PulseError):
+            align_measures([sched], self.cmd_def)
+
+    def test_align_across_schedules(self):
+        """Test that acquires are aligned together across multiple schedules."""
+        acquire = pulse.Acquire(5)
+        sched1 = pulse.Schedule(name='fake_experiment')
+        sched1 = sched1.insert(0, self.short_pulse(self.device.q[0].drive))
+        sched1 = sched1.insert(10, acquire(self.device.q[0], self.device.mem[0]))
+        sched2 = pulse.Schedule(name='fake_experiment')
+        sched2 = sched2.insert(3, self.short_pulse(self.device.q[0].drive))
+        sched2 = sched2.insert(25, acquire(self.device.q[0], self.device.mem[0]))
+        schedules = align_measures([sched1, sched2], self.cmd_def)
+        for time, inst in schedules[0].instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 25)
+        for time, inst in schedules[0].instructions:
+            if isinstance(inst, AcquireInstruction):
+                self.assertEqual(time, 25)
+
+
+class TestAddImplicitAcquires(QiskitTestCase):
+    """Test the helper function which makes implicit acquires explicit."""
+
+    def setUp(self):
+        self.backend = FakeOpenPulse2Q()
+        self.device = pulse.DeviceSpecification.create_from(self.backend)
+        self.config = self.backend.configuration()
+        self.defaults = self.backend.defaults()
+        self.cmd_def = CmdDef.from_defaults(self.defaults.cmd_def,
+                                            self.defaults.pulse_library)
+        self.short_pulse = pulse.SamplePulse(samples=np.array([0.02739068], dtype=np.complex128),
+                                             name='p0')
+        acquire = pulse.Acquire(5)
+        sched = pulse.Schedule(name='fake_experiment')
+        sched = sched.insert(0, self.short_pulse(self.device.q[0].drive))
+        self.sched = sched.insert(5, acquire(self.device.q, self.device.mem))
+
+    def test_add_implicit(self):
+        """Test that implicit acquires are made explicit according to the meas map."""
+        sched = add_implicit_acquires(self.sched, [[0, 1]])
+        acquired_qubits = set()
+        for _, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                acquired_qubits.update({a.index for a in inst.acquires})
+        self.assertEqual(acquired_qubits, {0, 1})
+
+    def test_add_across_meas_map_sublists(self):
+        """Test that implicit acquires in separate meas map sublists are all added."""
+        sched = add_implicit_acquires(self.sched, [[0, 2], [1, 3]])
+        acquired_qubits = set()
+        for _, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                acquired_qubits.update({a.index for a in inst.acquires})
+        self.assertEqual(acquired_qubits, {0, 1, 2, 3})
+
+    def test_dont_add_all(self):
+        """Test that acquires aren't added if no qubits in the sublist aren't being acquired."""
+        sched = add_implicit_acquires(self.sched, [[4, 5], [0, 2], [1, 3]])
+        acquired_qubits = set()
+        for _, inst in sched.instructions:
+            if isinstance(inst, AcquireInstruction):
+                acquired_qubits.update({a.index for a in inst.acquires})
+        self.assertEqual(acquired_qubits, {0, 1, 2, 3})
+
+
+if __name__ == '__main__':
+    unittest.main()