Repository: Qiskit/qiskit-terra (python)

Issue:
qpy changes names of `ControlledGate`s with non-default `ctrl_state`
### Environment

- **Qiskit Terra version**: 0.22.1
- **Python version**: 3.8
- **Operating system**: Ubuntu 22.04.1


### What is happening?

qpy appends an extra `"_o{ctrl_state}"` to the name of `qiskit.circuit.ControlledGate` instances with non-default values for `ctrl_state` (so after one serialization+deserialization round the `gate.name` becomes `"{name}_o{ctrl_state}_o{ctrl_state}"`, and after a second round becomes `"{name}_o{ctrl_state}_o{ctrl_state}_o{ctrl_state}"`, etc)

### How can we reproduce the issue?

```
import qiskit
import qiskit.qpy
import io

gate = qiskit.circuit.library.SGate().control(1, ctrl_state="0")  # create an instance of ControlledGate
print(gate.name)  # prints "cs_o0"

circuit = qiskit.circuit.QuantumCircuit(2)
circuit.append(gate, [0, 1])
print(circuit[0].operation.name)  # prints "cs_o0"

qiskit.qpy.dump(circuit, buf := io.BytesIO())
circuit = qiskit.qpy.load(io.BytesIO(buf.getbuffer()))[0]
print(circuit[0].operation.name)  # prints "cs_o0_o0"

qiskit.qpy.dump(circuit, buf := io.BytesIO())
circuit = qiskit.qpy.load(io.BytesIO(buf.getbuffer()))[0]
print(circuit[0].operation.name)  # prints "cs_o0_o0_o0"
```

### What should happen?

every `print` statement above should print `"cs_o0"`

### Any suggestions?

_No response_

Pull request description:
Fix QPY serialisation of ControlledGate with open controls
### Summary

Previously, an incorrect definition and name would be re-instated on
QPY deserialisation of a `ControlledGate` instance with open controls.
The base name would include the dynamic `_o{ctrl_state}` suffix, causing
the suffix to later be duplicated, and the definition would duplicate
the logic that added the open controls.  This fixes both by stripping
the suffix on re-read before it is assigned, and serialising only the
"11...1" state definition, since this is what is required and stored by
`ControlledGate`.

<!--
⚠️ 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.
-->

### Details and comments

Fix #8549.

This is technically a breaking change in the behaviour of QPY (it changes how something is serialised and deserialised), but it is not a format change.  A file created with the previous serialisation behaviour will still load with this new logic, and an older Qiskit will be able to deserialise a circuit dumped with this logic, but in both cases, a `ControlledGate` with open controls will not be restored to the expected name/definition.  There will be no exception, it'll just produce the wrong result, as it did before this patch.

Patch:
diff --git a/qiskit/qpy/binary_io/circuits.py b/qiskit/qpy/binary_io/circuits.py
index ff30f00e2..49ca334e4 100644
--- a/qiskit/qpy/binary_io/circuits.py
+++ b/qiskit/qpy/binary_io/circuits.py
@@ -320,6 +320,9 @@ def _parse_custom_operation(custom_operations, gate_name, params, version, vecto
             base_gate = _read_instruction(
                 base_gate_obj, None, registers, custom_operations, version, vectors
             )
+        if ctrl_state < 2**num_ctrl_qubits - 1:
+            # If open controls, we need to discard the control suffix when setting the name.
+            gate_name = gate_name.rsplit("_", 1)[0]
         inst_obj = ControlledGate(
             gate_name,
             num_qubits,
@@ -623,14 +626,21 @@ def _write_custom_operation(file_obj, name, operation, custom_operations):
         has_definition = True
         data = common.data_to_binary(operation, _write_pauli_evolution_gate)
         size = len(data)
-    elif operation.definition is not None:
+    elif type_key == type_keys.CircuitInstruction.CONTROLLED_GATE:
+        # For ControlledGate, we have to access and store the private `_definition` rather than the
+        # public one, because the public one is mutated to include additional logic if the control
+        # state is open, and the definition setter (during a subsequent read) uses the "fully
+        # excited" control definition only.
         has_definition = True
-        data = common.data_to_binary(operation.definition, write_circuit)
+        data = common.data_to_binary(operation._definition, write_circuit)
         size = len(data)
-    if type_key == type_keys.CircuitInstruction.CONTROLLED_GATE:
         num_ctrl_qubits = operation.num_ctrl_qubits
         ctrl_state = operation.ctrl_state
         base_gate = operation.base_gate
+    elif operation.definition is not None:
+        has_definition = True
+        data = common.data_to_binary(operation.definition, write_circuit)
+        size = len(data)
     if base_gate is None:
         base_gate_raw = b""
     else:
diff --git a/releasenotes/notes/fix-qpy-controlledgate-open-control-35c8ccb4c7466f4c.yaml b/releasenotes/notes/fix-qpy-controlledgate-open-control-35c8ccb4c7466f4c.yaml
new file mode 100644
index 000000000..d7a45238b
--- /dev/null
+++ b/releasenotes/notes/fix-qpy-controlledgate-open-control-35c8ccb4c7466f4c.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+  - |
+    Fixed QPY serialisation and deserialisation of :class:`.ControlledGate`
+    with open controls (*i.e.* those whose ``ctrl_state`` is not all ones).
+    Fixed `#8549 <https://github.com/Qiskit/qiskit-terra/issues/8549>`__.


Test patch:
diff --git a/test/python/circuit/test_circuit_load_from_qpy.py b/test/python/circuit/test_circuit_load_from_qpy.py
index 8292cee07..9cc5f2775 100644
--- a/test/python/circuit/test_circuit_load_from_qpy.py
+++ b/test/python/circuit/test_circuit_load_from_qpy.py
@@ -998,6 +998,17 @@ class TestLoadFromQPY(QiskitTestCase):
         new_circuit = load(qpy_file)[0]
         self.assertEqual(qc, new_circuit)
 
+    def test_controlled_gate_open_controls(self):
+        """Test a controlled gate with open controls round-trips exactly."""
+        qc = QuantumCircuit(3)
+        controlled_gate = DCXGate().control(1, ctrl_state=0)
+        qc.append(controlled_gate, [0, 1, 2])
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
     def test_nested_controlled_gate(self):
         """Test a custom nested controlled gate."""
         custom_gate = Gate("black_box", 1, [])
diff --git a/test/qpy_compat/test_qpy.py b/test/qpy_compat/test_qpy.py
index 8c23af7fd..418202f59 100755
--- a/test/qpy_compat/test_qpy.py
+++ b/test/qpy_compat/test_qpy.py
@@ -488,6 +488,30 @@ def generate_controlled_gates():
     return circuits
 
 
+def generate_open_controlled_gates():
+    """Test QPY serialization with custom ControlledGates with open controls."""
+    circuits = []
+    qc = QuantumCircuit(3)
+    controlled_gate = DCXGate().control(1, ctrl_state=0)
+    qc.append(controlled_gate, [0, 1, 2])
+    circuits.append(qc)
+
+    custom_gate = Gate("black_box", 1, [])
+    custom_definition = QuantumCircuit(1)
+    custom_definition.h(0)
+    custom_definition.rz(1.5, 0)
+    custom_definition.sdg(0)
+    custom_gate.definition = custom_definition
+    nested_qc = QuantumCircuit(3)
+    nested_qc.append(custom_gate, [0])
+    controlled_gate = custom_gate.control(2, ctrl_state=1)
+    nested_qc.append(controlled_gate, [0, 1, 2])
+    nested_qc.measure_all()
+    circuits.append(nested_qc)
+
+    return circuits
+
+
 def generate_circuits(version_str=None):
     """Generate reference circuits."""
     version_parts = None
@@ -525,6 +549,8 @@ def generate_circuits(version_str=None):
         output_circuits["controlled_gates.qpy"] = generate_controlled_gates()
         output_circuits["schedule_blocks.qpy"] = generate_schedule_blocks()
         output_circuits["pulse_gates.qpy"] = generate_calibrated_circuits()
+    if version_parts >= (0, 21, 2):
+        output_circuits["open_controlled_gates.qpy"] = generate_open_controlled_gates()
 
     return output_circuits