Repository: Qiskit/qiskit

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_


Discussion:

Resolving patch:
diff --git a/qiskit/qpy/binary_io/circuits.py b/qiskit/qpy/binary_io/circuits.py
--- 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:


Test patch:
diff --git a/test/python/circuit/test_circuit_load_from_qpy.py b/test/python/circuit/test_circuit_load_from_qpy.py
--- a/test/python/circuit/test_circuit_load_from_qpy.py
+++ b/test/python/circuit/test_circuit_load_from_qpy.py
@@ -998,6 +998,17 @@ def test_controlled_gate(self):
         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
--- 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