Repository: Qiskit/qiskit

Issue:
Instruction duration is parameter agnostic
### What is the expected enhancement?

## Summary

The class `InstructionDurations` in `qiskit.transpiler.instruction_durations.py` is agnostic to parameter values. This poses problems for gates that have a parameter-dependent duration.

## Details

Instruction durations are created using e.g. the following code:
```python
durations = InstructionDurations(
    [("h", 0, 50), ("cx", [0, 1], 700), ("reset", None, 10),
     ("cx", [1, 2], 200), ("cx", [2, 3], 300),
     ("x", None, 50), ("measure", None, 1000)]
)
```
Here, for each tuple, the first argument is the name of the instruction, the second argument is the qubits it applies to, and the third argument is the duration. The problem with this structure is that it does not have the parameters of the instructions. This is fine for basis gates like `["x", "h", "cx"]` and even `rz(theta)` since it does not have a duration. However, this is not sufficient for cases such as `rzx(theta)` in which the same instruction on the same pair of qubits can have a different duration depending on the parameter `theta`.

## Proposed solution

We can add an additional entry to the tuples in `InstructionDurations` that corresponds to the parameters of the instruction. E.g. `(inst_name, qubits, params, duration)`. This will also better reflect the information of the `QuantumCircuit.calibrations` variable which contains the schedule for a given instruction, qubits, and parameter values of the instruction.


Discussion:

Resolving patch:
diff --git a/qiskit/compiler/transpiler.py b/qiskit/compiler/transpiler.py
--- a/qiskit/compiler/transpiler.py
+++ b/qiskit/compiler/transpiler.py
@@ -948,8 +948,8 @@ def _parse_instruction_durations(backend, inst_durations, dt, circuits):
         if circ.calibrations:
             cal_durations = []
             for gate, gate_cals in circ.calibrations.items():
-                for (qubits, _), schedule in gate_cals.items():
-                    cal_durations.append((gate, qubits, schedule.duration))
+                for (qubits, parameters), schedule in gate_cals.items():
+                    cal_durations.append((gate, qubits, parameters, schedule.duration))
             circ_durations.update(cal_durations, circ_durations.dt)
 
         if inst_durations:
diff --git a/qiskit/transpiler/instruction_durations.py b/qiskit/transpiler/instruction_durations.py
--- a/qiskit/transpiler/instruction_durations.py
+++ b/qiskit/transpiler/instruction_durations.py
@@ -27,7 +27,11 @@ class InstructionDurations:
 
     It stores durations (gate lengths) and dt to be used at the scheduling stage of transpiling.
     It can be constructed from ``backend`` or ``instruction_durations``,
-    which is an argument of :func:`transpile`.
+    which is an argument of :func:`transpile`. The duration of an instruction depends on the
+    instruction (given by name), the qubits, and optionally the parameters of the instruction.
+    Note that these fields are used as keys in dictionaries that are used to retrieve the
+    instruction durations. Therefore, users must use the exact same parameter value to retrieve
+    an instruction duration as the value with which it was added.
     """
 
     def __init__(
@@ -35,6 +39,7 @@ def __init__(
     ):
         self.duration_by_name = {}
         self.duration_by_name_qubits = {}
+        self.duration_by_name_qubits_params = {}
         self.dt = dt
         if instruction_durations:
             self.update(instruction_durations)
@@ -108,25 +113,48 @@ def update(self, inst_durations: Optional["InstructionDurationsType"], dt: float
         if isinstance(inst_durations, InstructionDurations):
             self.duration_by_name.update(inst_durations.duration_by_name)
             self.duration_by_name_qubits.update(inst_durations.duration_by_name_qubits)
+            self.duration_by_name_qubits_params.update(
+                inst_durations.duration_by_name_qubits_params
+            )
         else:
             for i, items in enumerate(inst_durations):
-                if len(items) == 3:
-                    inst_durations[i] = (*items, "dt")  # set default unit
-                elif len(items) != 4:
+
+                if not isinstance(items[-1], str):
+                    items = (*items, "dt")  # set default unit
+
+                if len(items) == 4:  # (inst_name, qubits, duration, unit)
+                    inst_durations[i] = (*items[:3], None, items[3])
+                else:
+                    inst_durations[i] = items
+
+                # assert (inst_name, qubits, duration, parameters, unit)
+                if len(inst_durations[i]) != 5:
                     raise TranspilerError(
                         "Each entry of inst_durations dictionary must be "
                         "(inst_name, qubits, duration) or "
-                        "(inst_name, qubits, duration, unit)"
+                        "(inst_name, qubits, duration, unit) or"
+                        "(inst_name, qubits, duration, parameters) or"
+                        "(inst_name, qubits, duration, parameters, unit) "
+                        f"received {inst_durations[i]}."
                     )
 
-            for name, qubits, duration, unit in inst_durations:
+                if inst_durations[i][2] is None:
+                    raise TranspilerError(f"None duration for {inst_durations[i]}.")
+
+            for name, qubits, duration, parameters, unit in inst_durations:
                 if isinstance(qubits, int):
                     qubits = [qubits]
 
+                if isinstance(parameters, (int, float)):
+                    parameters = [parameters]
+
                 if qubits is None:
                     self.duration_by_name[name] = duration, unit
-                else:
+                elif parameters is None:
                     self.duration_by_name_qubits[(name, tuple(qubits))] = duration, unit
+                else:
+                    key = (name, tuple(qubits), tuple(parameters))
+                    self.duration_by_name_qubits_params[key] = duration, unit
 
         return self
 
@@ -135,13 +163,17 @@ def get(
         inst: Union[str, Instruction],
         qubits: Union[int, List[int], Qubit, List[Qubit]],
         unit: str = "dt",
+        parameters: Optional[List[float]] = None,
     ) -> float:
-        """Get the duration of the instruction with the name and the qubits.
+        """Get the duration of the instruction with the name, qubits, and parameters.
+
+        Some instructions may have a parameter dependent duration.
 
         Args:
             inst: An instruction or its name to be queried.
             qubits: Qubits or its indices that the instruction acts on.
             unit: The unit of duration to be returned. It must be 's' or 'dt'.
+            parameters: The value of the parameters of the desired instruction.
 
         Returns:
             float|int: The duration of the instruction on the qubits.
@@ -174,19 +206,31 @@ def get(
             qubits = [q.index for q in qubits]
 
         try:
-            return self._get(inst_name, qubits, unit)
+            return self._get(inst_name, qubits, unit, parameters)
         except TranspilerError as ex:
             raise TranspilerError(
                 f"Duration of {inst_name} on qubits {qubits} is not found."
             ) from ex
 
-    def _get(self, name: str, qubits: List[int], to_unit: str) -> float:
-        """Get the duration of the instruction with the name and the qubits."""
+    def _get(
+        self,
+        name: str,
+        qubits: List[int],
+        to_unit: str,
+        parameters: Optional[Iterable[float]] = None,
+    ) -> float:
+        """Get the duration of the instruction with the name, qubits, and parameters."""
         if name == "barrier":
             return 0
 
-        key = (name, tuple(qubits))
-        if key in self.duration_by_name_qubits:
+        if parameters is not None:
+            key = (name, tuple(qubits), tuple(parameters))
+        else:
+            key = (name, tuple(qubits))
+
+        if key in self.duration_by_name_qubits_params:
+            duration, unit = self.duration_by_name_qubits_params[key]
+        elif key in self.duration_by_name_qubits:
             duration, unit = self.duration_by_name_qubits[key]
         elif name in self.duration_by_name:
             duration, unit = self.duration_by_name[name]
@@ -232,8 +276,10 @@ def units_used(self) -> Set[str]:
 
 
 InstructionDurationsType = Union[
+    List[Tuple[str, Optional[Iterable[int]], float, Optional[Iterable[float]], str]],
+    List[Tuple[str, Optional[Iterable[int]], float, Optional[Iterable[float]]]],
     List[Tuple[str, Optional[Iterable[int]], float, str]],
     List[Tuple[str, Optional[Iterable[int]], float]],
     InstructionDurations,
 ]
-"""List of tuples representing (instruction name, qubits indices, duration)."""
+"""List of tuples representing (instruction name, qubits indices, parameters, duration)."""


Test patch:
diff --git a/test/python/transpiler/test_dynamical_decoupling.py b/test/python/transpiler/test_dynamical_decoupling.py
--- a/test/python/transpiler/test_dynamical_decoupling.py
+++ b/test/python/transpiler/test_dynamical_decoupling.py
@@ -15,6 +15,7 @@
 import unittest
 import numpy as np
 from numpy import pi
+from ddt import ddt, data
 
 from qiskit.circuit import QuantumCircuit, Delay
 from qiskit.circuit.library import XGate, YGate, RXGate, UGate
@@ -24,9 +25,12 @@
 from qiskit.transpiler.passmanager import PassManager
 from qiskit.transpiler.exceptions import TranspilerError
 
+import qiskit.pulse as pulse
+
 from qiskit.test import QiskitTestCase
 
 
+@ddt
 class TestDynamicalDecoupling(QiskitTestCase):
     """Tests DynamicalDecoupling pass."""
 
@@ -595,6 +599,29 @@ def test_insert_dd_bad_sequence(self):
         with self.assertRaises(TranspilerError):
             pm.run(self.ghz4)
 
+    @data(0.5, 1.5)
+    def test_dd_with_calibrations_with_parameters(self, param_value):
+        """Check that calibrations in a circuit with parameters work fine."""
+
+        circ = QuantumCircuit(2)
+        circ.x(0)
+        circ.cx(0, 1)
+        circ.rx(param_value, 1)
+
+        rx_duration = int(param_value * 1000)
+
+        with pulse.build() as rx:
+            pulse.play(pulse.Gaussian(rx_duration, 0.1, rx_duration // 4), pulse.DriveChannel(1))
+
+        circ.add_calibration("rx", (1,), rx, params=[param_value])
+
+        durations = InstructionDurations([("x", None, 100), ("cx", None, 300)])
+
+        dd_sequence = [XGate(), XGate()]
+        pm = PassManager([ALAPSchedule(durations), DynamicalDecoupling(durations, dd_sequence)])
+
+        self.assertEqual(pm.run(circ).duration, rx_duration + 100 + 300)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/python/transpiler/test_instruction_durations.py b/test/python/transpiler/test_instruction_durations.py
--- a/test/python/transpiler/test_instruction_durations.py
+++ b/test/python/transpiler/test_instruction_durations.py
@@ -52,6 +52,14 @@ def test_from_backend_for_backend_without_dt(self):
         with self.assertRaises(TranspilerError):
             durations.get(gate, 0)
 
+    def test_update_with_parameters(self):
+        durations = InstructionDurations(
+            [("rzx", (0, 1), 150, (0.5,)), ("rzx", (0, 1), 300, (1.0,))]
+        )
+
+        self.assertEqual(durations.get("rzx", [0, 1], parameters=[0.5]), 150)
+        self.assertEqual(durations.get("rzx", [0, 1], parameters=[1.0]), 300)
+
     def _find_gate_with_length(self, backend):
         """Find a gate that has gate length."""
         props = backend.properties()