Repository: Qiskit/qiskit

Issue:
Dynamical decoupling fails when a gate in the decoupling sequence has a calibration attached to the circuit
### Environment

- **Qiskit Terra version**: 0.25.1
- **Python version**: 3.10
- **Operating system**: Fedora Linux


### What is happening?

`PadDynamicalDecoupling` raises `TypeError: unhashable type: 'list'` when run on a circuit with a calibration for one of the gates in the decoupling sequence.

### How can we reproduce the issue?

```python
from qiskit_ibm_provider import IBMProvider


provider = IBMProvider()
backend = provider.get_backend("ibm_nazca")


import numpy as np

from qiskit import pulse, QuantumCircuit
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import ALAPScheduleAnalysis, PadDynamicalDecoupling
from qiskit.circuit.library import XGate, YGate
from qiskit.pulse import DriveChannel


inst_sched_map = backend.instruction_schedule_map


circ = QuantumCircuit(1, 1)
circ.x(0)
circ.delay(1600, 0)
circ.x(0)

for qubit in range(backend.configuration().n_qubits):
    with pulse.build(f"y gate for qubit {qubit}") as sched:
        with pulse.phase_offset(np.pi / 2, DriveChannel(qubit)):
            x_sched = inst_sched_map.get("x", qubits=[qubit])
            pulse.call(x_sched)
        circ.add_calibration("y", [qubit], sched)

dd_sequence = [XGate(), YGate()]*4
durations = backend.target.durations()

pm = PassManager(
        [
            ALAPScheduleAnalysis(durations),
            PadDynamicalDecoupling(
                durations,
                dd_sequence,
                pulse_alignment=backend.configuration().timing_constraints[
                    "pulse_alignment"
                ],
            ),
        ]
    )

pm.run(circ)
```

### What should happen?

The duration of the gate calibration should be used to schedule the decoupling sequence.

### Any suggestions?

A PR with a fix will be opened shortly.


Discussion:

Resolving patch:
diff --git a/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py b/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py
--- a/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py
+++ b/qiskit/transpiler/passes/scheduling/padding/dynamical_decoupling.py
@@ -16,7 +16,7 @@
 import logging
 import numpy as np
 
-from qiskit.circuit import Qubit, Gate
+from qiskit.circuit import Gate, ParameterExpression, Qubit
 from qiskit.circuit.delay import Delay
 from qiskit.circuit.library.standard_gates import IGate, UGate, U3Gate
 from qiskit.circuit.reset import Reset
@@ -227,7 +227,8 @@ def _pre_runhook(self, dag: DAGCircuit):
             for gate in self._dd_sequence:
                 try:
                     # Check calibration.
-                    gate_length = dag.calibrations[gate.name][(physical_index, gate.params)]
+                    params = self._resolve_params(gate)
+                    gate_length = dag.calibrations[gate.name][((physical_index,), params)].duration
                     if gate_length % self._alignment != 0:
                         # This is necessary to implement lightweight scheduling logic for this pass.
                         # Usually the pulse alignment constraint and pulse data chunk size take
@@ -394,6 +395,17 @@ def _constrained_length(values):
 
         dag.global_phase = self._mod_2pi(dag.global_phase + sequence_gphase)
 
+    @staticmethod
+    def _resolve_params(gate: Gate) -> tuple:
+        """Return gate params with any bound parameters replaced with floats"""
+        params = []
+        for p in gate.params:
+            if isinstance(p, ParameterExpression) and not p.parameters:
+                params.append(float(p))
+            else:
+                params.append(p)
+        return tuple(params)
+
     @staticmethod
     def _mod_2pi(angle: float, atol: float = 0):
         """Wrap angle into interval [-π,π). If within atol of the endpoint, clamp to -π"""


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
@@ -17,7 +17,8 @@
 from numpy import pi
 from ddt import ddt, data
 
-from qiskit.circuit import QuantumCircuit, Delay, Measure, Reset, Parameter
+from qiskit import pulse
+from qiskit.circuit import Gate, QuantumCircuit, Delay, Measure, Reset, Parameter
 from qiskit.circuit.library import XGate, YGate, RXGate, UGate, CXGate, HGate
 from qiskit.quantum_info import Operator
 from qiskit.transpiler.instruction_durations import InstructionDurations
@@ -29,7 +30,6 @@
 from qiskit.transpiler.passmanager import PassManager
 from qiskit.transpiler.exceptions import TranspilerError
 from qiskit.transpiler.target import Target, InstructionProperties
-from qiskit import pulse
 
 from qiskit.test import QiskitTestCase
 
@@ -339,6 +339,154 @@ def test_insert_dd_ghz_everywhere(self):
 
         self.assertEqual(ghz4_dd, expected)
 
+    def test_insert_dd_with_pulse_gate_calibrations(self):
+        """Test DD gates are inserted without error when circuit calibrations are used
+
+                      ┌───┐            ┌───────────────┐      ┌───┐       »
+           q_0: ──────┤ H ├─────────■──┤ Delay(75[dt]) ├──────┤ X ├───────»
+                ┌─────┴───┴─────┐ ┌─┴─┐└───────────────┘┌─────┴───┴──────┐»
+           q_1: ┤ Delay(50[dt]) ├─┤ X ├────────■────────┤ Delay(300[dt]) ├»
+                ├───────────────┴┐└───┘      ┌─┴─┐      └────────────────┘»
+           q_2: ┤ Delay(750[dt]) ├───────────┤ X ├──────────────■─────────»
+                ├────────────────┤           └───┘            ┌─┴─┐       »
+           q_3: ┤ Delay(950[dt]) ├────────────────────────────┤ X ├───────»
+                └────────────────┘                            └───┘       »
+        meas: 4/══════════════════════════════════════════════════════════»
+                                                                           »
+        «        ┌────────────────┐┌───┐┌───────────────┐ ░ ┌─┐
+        «   q_0: ┤ Delay(150[dt]) ├┤ X ├┤ Delay(75[dt]) ├─░─┤M├─────────
+        «        └────────────────┘└───┘└───────────────┘ ░ └╥┘┌─┐
+        «   q_1: ─────────────────────────────────────────░──╫─┤M├──────
+        «                                                 ░  ║ └╥┘┌─┐
+        «   q_2: ─────────────────────────────────────────░──╫──╫─┤M├───
+        «                                                 ░  ║  ║ └╥┘┌─┐
+        «   q_3: ─────────────────────────────────────────░──╫──╫──╫─┤M├
+        «                                                 ░  ║  ║  ║ └╥┘
+        «meas: 4/════════════════════════════════════════════╩══╩══╩══╩═
+        «                                                     0  1  2  3
+        """
+        dd_sequence = [XGate(), XGate()]
+        pm = PassManager(
+            [
+                ALAPScheduleAnalysis(self.durations),
+                PadDynamicalDecoupling(self.durations, dd_sequence, qubits=[0]),
+            ]
+        )
+
+        # Change duration to 100 from the 50 in self.durations to make sure
+        # gate duration is used correctly.
+        with pulse.builder.build() as x_sched:
+            pulse.builder.delay(100, pulse.DriveChannel(0))
+
+        circ_in = self.ghz4.measure_all(inplace=False)
+        circ_in.add_calibration(XGate(), (0,), x_sched)
+
+        ghz4_dd = pm.run(circ_in)
+
+        expected = self.ghz4.copy()
+        expected = expected.compose(Delay(50), [1], front=True)
+        expected = expected.compose(Delay(750), [2], front=True)
+        expected = expected.compose(Delay(950), [3], front=True)
+
+        # Delays different from those of the default case using self.durations
+        expected = expected.compose(Delay(75), [0])
+        expected = expected.compose(XGate(), [0])
+        expected = expected.compose(Delay(150), [0])
+        expected = expected.compose(XGate(), [0])
+        expected = expected.compose(Delay(75), [0])
+
+        expected = expected.compose(Delay(300), [1])
+
+        expected.measure_all()
+        expected.add_calibration(XGate(), (0,), x_sched)
+
+        self.assertEqual(ghz4_dd, expected)
+
+    def test_insert_dd_with_pulse_gate_calibrations_with_parmas(self):
+        """Test DD gates are inserted without error when parameterized circuit calibrations are used
+
+                      ┌───┐            ┌───────────────┐      ┌───┐       »
+           q_0: ──────┤ H ├─────────■──┤ Delay(75[dt]) ├──────┤ X ├───────»
+                ┌─────┴───┴─────┐ ┌─┴─┐└───────────────┘┌─────┴───┴──────┐»
+           q_1: ┤ Delay(50[dt]) ├─┤ X ├────────■────────┤ Delay(300[dt]) ├»
+                ├───────────────┴┐└───┘      ┌─┴─┐      └────────────────┘»
+           q_2: ┤ Delay(750[dt]) ├───────────┤ X ├──────────────■─────────»
+                ├────────────────┤           └───┘            ┌─┴─┐       »
+           q_3: ┤ Delay(950[dt]) ├────────────────────────────┤ X ├───────»
+                └────────────────┘                            └───┘       »
+        meas: 4/══════════════════════════════════════════════════════════»
+                                                                           »
+        «        ┌────────────────┐┌───┐┌───────────────┐ ░ ┌─┐
+        «   q_0: ┤ Delay(150[dt]) ├┤ X ├┤ Delay(75[dt]) ├─░─┤M├─────────
+        «        └────────────────┘└───┘└───────────────┘ ░ └╥┘┌─┐
+        «   q_1: ─────────────────────────────────────────░──╫─┤M├──────
+        «                                                 ░  ║ └╥┘┌─┐
+        «   q_2: ─────────────────────────────────────────░──╫──╫─┤M├───
+        «                                                 ░  ║  ║ └╥┘┌─┐
+        «   q_3: ─────────────────────────────────────────░──╫──╫──╫─┤M├
+        «                                                 ░  ║  ║  ║ └╥┘
+        «meas: 4/════════════════════════════════════════════╩══╩══╩══╩═
+        «                                                     0  1  2  3
+        """
+        # Change duration to 100 from the 50 in self.durations to make sure
+        # gate duration is used correctly.
+        amp = Parameter("amp")
+        with pulse.builder.build() as sched:
+            pulse.builder.play(
+                pulse.Gaussian(100, amp=amp, sigma=10.0),
+                pulse.DriveChannel(0),
+            )
+
+        class Echo(Gate):
+            """Dummy Gate subclass for testing
+
+            In this test, we use a non-standard gate so we can add parameters
+            to it, in order to test the handling of parameters by
+            PadDynamicalDecoupling. PadDynamicalDecoupling checks that the DD
+            sequence is equivalent to the identity, so we can not use Gate
+            directly. Here we subclass Gate and add the identity as its matrix
+            representation to satisfy PadDynamicalDecoupling's check.
+            """
+
+            def __array__(self, dtype=None):
+                return np.eye(2, dtype=dtype)
+
+        # A gate with one unbound and one bound parameter to leave in the final
+        # circuit.
+        echo = Echo("echo", 1, [amp, 10.0])
+
+        circ_in = self.ghz4.measure_all(inplace=False)
+        circ_in.add_calibration(echo, (0,), sched)
+
+        dd_sequence = [echo, echo]
+        pm = PassManager(
+            [
+                ALAPScheduleAnalysis(self.durations),
+                PadDynamicalDecoupling(self.durations, dd_sequence, qubits=[0]),
+            ]
+        )
+
+        ghz4_dd = pm.run(circ_in)
+
+        expected = self.ghz4.copy()
+        expected = expected.compose(Delay(50), [1], front=True)
+        expected = expected.compose(Delay(750), [2], front=True)
+        expected = expected.compose(Delay(950), [3], front=True)
+
+        # Delays different from those of the default case using self.durations
+        expected = expected.compose(Delay(75), [0])
+        expected = expected.compose(echo, [0])
+        expected = expected.compose(Delay(150), [0])
+        expected = expected.compose(echo, [0])
+        expected = expected.compose(Delay(75), [0])
+
+        expected = expected.compose(Delay(300), [1])
+
+        expected.measure_all()
+        expected.add_calibration(echo, (0,), sched)
+
+        self.assertEqual(ghz4_dd, expected)
+
     def test_insert_dd_ghz_xy4(self):
         """Test XY4 sequence of DD gates.