Repository: Qiskit/qiskit (python)

Issue:
Support binding/assigning parameters by name
<!-- ⚠️ 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?

From @Cryoris 's comment in https://github.com/Qiskit/qiskit-terra/issues/5557#issuecomment-757520462 :

> Personally I'd be fine if that'd work with a string as well, since parameter names are unique in circuits.
> ```
> partially_bound = circuit.assign_parameters({'x': value})
> ```

We should consider adding support for binding and assigning parameters via their string name. This was originally omitted for concerns over overlapping parameter names between different circuits. However, by the stage of `bind_`/`assign_`, the `Parameter` s on a given circuit are fixed and guaranteed to be unique, so there isn't really a possibility for the ambiguity that exists when composing two parameterized circuits.

Pull request:
Allow string keys in `QuantumCircuit.assign_parameters`
<!--
⚠️ If you do not respect this template, your pull request will be closed.
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Also, please add a release note file using reno if the change needs to be
  documented in the release notes.
⚠️ If your pull request fixes an open issue, please link to the issue.

- [ ] I have added the tests to cover my changes.
- [ ] I have updated the documentation accordingly.
- [ ] I have read the CONTRIBUTING document.
-->

### Summary

These are looked up using the new `get_parameter` method to convert them into an actual `Parameter` instance.  This is not available in the fast path (which is allowed to assume that the caller has taken care of providing the most efficient inputs).


### Details and comments

~Depends on #11431, which this PR currently contains.~ *edit*: now rebased.

Close #7107.


Patch:
diff --git a/qiskit/circuit/quantumcircuit.py b/qiskit/circuit/quantumcircuit.py
index 27c01d38b..4cfc06d15 100644
--- a/qiskit/circuit/quantumcircuit.py
+++ b/qiskit/circuit/quantumcircuit.py
@@ -953,9 +953,10 @@ class QuantumCircuit:
                 "Cannot emit a new composed circuit while a control-flow context is active."
             )
 
+        # Avoid mutating `dest` until as much of the error checking as possible is complete, to
+        # avoid an in-place composition getting `self` in a partially mutated state for a simple
+        # error that the user might want to correct in an interactive session.
         dest = self if inplace else self.copy()
-        dest.duration = None
-        dest.unit = "dt"
 
         # As a special case, allow composing some clbits onto no clbits - normally the destination
         # has to be strictly larger. This allows composing final measurements onto unitary circuits.
@@ -997,23 +998,9 @@ class QuantumCircuit:
                 "Trying to compose with another QuantumCircuit which has more 'in' edges."
             )
 
-        for gate, cals in other.calibrations.items():
-            dest._calibrations[gate].update(cals)
-
-        dest.global_phase += other.global_phase
-
-        if not other.data:
-            # Nothing left to do. Plus, accessing 'data' here is necessary
-            # to trigger any lazy building since we now access '_data'
-            # directly.
-            return None if inplace else dest
-
-        # The 'qubits' and 'clbits' used for 'dest'.
+        # Maps bits in 'other' to bits in 'dest'.
         mapped_qubits: list[Qubit]
         mapped_clbits: list[Clbit]
-
-        # Maps bits in 'other' to bits in 'dest'. Used only for
-        # adjusting bits in variables (e.g. condition and target).
         edge_map: dict[Qubit | Clbit, Qubit | Clbit] = {}
         if qubits is None:
             mapped_qubits = dest.qubits
@@ -1025,6 +1012,10 @@ class QuantumCircuit:
                     f"Number of items in qubits parameter ({len(mapped_qubits)}) does not"
                     f" match number of qubits in the circuit ({len(other.qubits)})."
                 )
+            if len(set(mapped_qubits)) != len(mapped_qubits):
+                raise CircuitError(
+                    f"Duplicate qubits referenced in 'qubits' parameter: '{mapped_qubits}'"
+                )
             edge_map.update(zip(other.qubits, mapped_qubits))
 
         if clbits is None:
@@ -1037,8 +1028,25 @@ class QuantumCircuit:
                     f"Number of items in clbits parameter ({len(mapped_clbits)}) does not"
                     f" match number of clbits in the circuit ({len(other.clbits)})."
                 )
+            if len(set(mapped_clbits)) != len(mapped_clbits):
+                raise CircuitError(
+                    f"Duplicate clbits referenced in 'clbits' parameter: '{mapped_clbits}'"
+                )
             edge_map.update(zip(other.clbits, dest.cbit_argument_conversion(clbits)))
 
+        for gate, cals in other.calibrations.items():
+            dest._calibrations[gate].update(cals)
+
+        dest.duration = None
+        dest.unit = "dt"
+        dest.global_phase += other.global_phase
+
+        if not other.data:
+            # Nothing left to do. Plus, accessing 'data' here is necessary
+            # to trigger any lazy building since we now access '_data'
+            # directly.
+            return None if inplace else dest
+
         variable_mapper = _classical_resource_map.VariableMapper(
             dest.cregs, edge_map, dest.add_register
         )
@@ -3075,7 +3083,7 @@ class QuantumCircuit:
     ) -> Optional["QuantumCircuit"]:
         """Assign parameters to new parameters or values.
 
-        If ``parameters`` is passed as a dictionary, the keys must be :class:`.Parameter`
+        If ``parameters`` is passed as a dictionary, the keys should be :class:`.Parameter`
         instances in the current circuit. The values of the dictionary can either be numeric values
         or new parameter objects.
 
@@ -3085,6 +3093,16 @@ class QuantumCircuit:
 
         The values can be assigned to the current circuit object or to a copy of it.
 
+        .. note::
+            When ``parameters`` is given as a mapping, it is permissible to have keys that are
+            strings of the parameter names; these will be looked up using :meth:`get_parameter`.
+            You can also have keys that are :class:`.ParameterVector` instances, and in this case,
+            the dictionary value should be a sequence of values of the same length as the vector.
+
+            If you use either of these cases, you must leave the setting ``flat_input=False``;
+            changing this to ``True`` enables the fast path, where all keys must be
+            :class:`.Parameter` instances.
+
         Args:
             parameters: Either a dictionary or iterable specifying the new parameter values.
             inplace: If False, a copy of the circuit with the bound parameters is returned.
@@ -3092,7 +3110,9 @@ class QuantumCircuit:
             flat_input: If ``True`` and ``parameters`` is a mapping type, it is assumed to be
                 exactly a mapping of ``{parameter: value}``.  By default (``False``), the mapping
                 may also contain :class:`.ParameterVector` keys that point to a corresponding
-                sequence of values, and these will be unrolled during the mapping.
+                sequence of values, and these will be unrolled during the mapping, or string keys,
+                which will be converted to :class:`.Parameter` instances using
+                :meth:`get_parameter`.
             strict: If ``False``, any parameters given in the mapping that are not used in the
                 circuit will be ignored.  If ``True`` (the default), an error will be raised
                 indicating a logic error.
@@ -3270,9 +3290,8 @@ class QuantumCircuit:
         )
         return None if inplace else target
 
-    @staticmethod
     def _unroll_param_dict(
-        parameter_binds: Mapping[Parameter, ParameterValueType]
+        self, parameter_binds: Mapping[Parameter, ParameterValueType]
     ) -> Mapping[Parameter, ParameterValueType]:
         out = {}
         for parameter, value in parameter_binds.items():
@@ -3283,6 +3302,8 @@ class QuantumCircuit:
                         f" but was assigned to {len(value)} values."
                     )
                 out.update(zip(parameter, value))
+            elif isinstance(parameter, str):
+                out[self.get_parameter(parameter)] = value
             else:
                 out[parameter] = value
         return out
diff --git a/releasenotes/notes/assign-by-name-305f2bbf89099174.yaml b/releasenotes/notes/assign-by-name-305f2bbf89099174.yaml
new file mode 100644
index 000000000..a675dd08b
--- /dev/null
+++ b/releasenotes/notes/assign-by-name-305f2bbf89099174.yaml
@@ -0,0 +1,14 @@
+---
+features:
+  - |
+    :meth:`.QuantumCircuit.assign_parameters` now accepts string keys in the mapping form of input.
+    These names are used to look up the corresponding :class:`.Parameter` instance using
+    :meth:`~.QuantumCircuit.get_parameter`.  This lets you do::
+
+      from qiskit.circuit import QuantumCircuit, Parameter
+
+      a = Parameter("a")
+      qc = QuantumCircuit(1)
+      qc.rx(a, 0)
+
+      qc.assign_parameters({"a": 1}) == qc.assign_parameters({a: 1})
diff --git a/releasenotes/notes/fix-circuit-compose-duplicate-59de6c1c51f58e54.yaml b/releasenotes/notes/fix-circuit-compose-duplicate-59de6c1c51f58e54.yaml
new file mode 100644
index 000000000..fbeb2d33c
--- /dev/null
+++ b/releasenotes/notes/fix-circuit-compose-duplicate-59de6c1c51f58e54.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+  - |
+    :meth:`.QuantumCircuit.compose` will now correctly raise a :exc:`.CircuitError` when there are
+    duplicates in the ``qubits`` or ``clbits`` arguments.


Test patch:
diff --git a/test/python/circuit/test_compose.py b/test/python/circuit/test_compose.py
index 70eaffb0f..1c6d00915 100644
--- a/test/python/circuit/test_compose.py
+++ b/test/python/circuit/test_compose.py
@@ -31,6 +31,7 @@ from qiskit.circuit import (
     Instruction,
     CASE_DEFAULT,
     SwitchCaseOp,
+    CircuitError,
 )
 from qiskit.circuit.library import HGate, RZGate, CXGate, CCXGate, TwoLocal
 from qiskit.circuit.classical import expr
@@ -880,6 +881,16 @@ class TestCircuitCompose(QiskitTestCase):
 
         self.assertEqual(dest, expected)
 
+    def test_rejects_duplicate_bits(self):
+        """Test that compose rejects duplicates in either qubits or clbits."""
+        base = QuantumCircuit(5, 5)
+
+        attempt = QuantumCircuit(2, 2)
+        with self.assertRaisesRegex(CircuitError, "Duplicate qubits"):
+            base.compose(attempt, [1, 1], [0, 1])
+        with self.assertRaisesRegex(CircuitError, "Duplicate clbits"):
+            base.compose(attempt, [0, 1], [1, 1])
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/python/circuit/test_parameters.py b/test/python/circuit/test_parameters.py
index a25082df0..900f5641c 100644
--- a/test/python/circuit/test_parameters.py
+++ b/test/python/circuit/test_parameters.py
@@ -244,6 +244,18 @@ class TestParameters(QiskitTestCase):
         c = a.bind({a: 1, b: 1}, allow_unknown_parameters=True)
         self.assertEqual(c, a.bind({a: 1}))
 
+    def test_assign_parameters_by_name(self):
+        """Test that parameters can be assigned by name as well as value."""
+        a = Parameter("a")
+        b = Parameter("b")
+        c = Parameter("c")
+        qc = QuantumCircuit(2, global_phase=a * 2)
+        qc.rx(b + 0.125 * c, 0)
+
+        self.assertEqual(
+            qc.assign_parameters({a: 1, b: 2, c: 3}), qc.assign_parameters({"a": 1, "b": 2, "c": 3})
+        )
+
     def test_bind_parameters_custom_definition_global_phase(self):
         """Test that a custom gate with a parametrised `global_phase` is assigned correctly."""
         x = Parameter("x")
@@ -536,7 +548,7 @@ class TestParameters(QiskitTestCase):
         with self.assertRaises(CircuitError):
             qc.assign_parameters({z: [3, 4, 5]})
         with self.assertRaises(CircuitError):
-            qc.assign_parameters({"a_str": 6})
+            qc.assign_parameters({6: 6})
         with self.assertRaises(CircuitError):
             qc.assign_parameters({None: 7})