Repository: Qiskit/qiskit

Issue:
Calibrations involving bound parameters fail to roundtrip through QPY
### Environment

- **Qiskit Terra version**: 0.23.2
- **Python version**: 3.10
- **Operating system**: macOS


### What is happening?

Calibrations that are defined for gates whose parameters are precisely of type `float` seem to roundtrip through QPY just fine.  However, if the calibration is made to a gate whose parameters are fully bound `ParameterExpression`s, then a QPY-roundtripped circuit doesn't seem to recognise the calibration any more.

### How can we reproduce the issue?

```python
import io
from qiskit import QuantumCircuit, transpile, qpy
from qiskit.circuit import ParameterVector
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import RZXCalibrationBuilder
from qiskit.providers.fake_provider import FakePerth

backend = FakePerth()
inst_sched_map = backend.instruction_schedule_map
channel_map = backend.channels_map

param_bind = [-1.7131982974314244]

qc1 = QuantumCircuit(2)
qc1.rzx(param_bind[0], 0, 1)
qc1.draw()

qc2 = QuantumCircuit(2)
thetas = ParameterVector('θ', 1)
qc2.rzx(thetas[0], 0, 1)

pm = PassManager(RZXCalibrationBuilder(inst_sched_map, channel_map))
qc1_t = transpile(pm.run(qc1), backend)
qc2_t = transpile(pm.run(qc2.assign_parameters(param_bind)), backend)

def roundtrip(qc):
    with io.BytesIO() as fptr:
        qpy.dump(qc, fptr)
        fptr.seek(0)
        return qpy.load(fptr)[0]

def check_calibration(qc):
    return qc.has_calibration_for(qc.data[0])

[check_calibration(x) for x in [qc1_t, qc2_t, roundtrip(qc1_t), roundtrip(qc2_t)]]
```
gives
```python
[True, True, True, False]
```

Notably, `roundtrip(qc2_t)` still _does_ appear to have the requisite calibration, it's just not quite recognised correctly:
```python
>>> roundtrip(qc2_t).calibrations
{'rzx': {((0, 1), (-1.7131982974314244,)): ScheduleBlock(...)}}
```

### What should happen?

The roundtripped circuit should still recognise that it has a calibration for that instruction.

### Any suggestions?

I _think_ what's happening is that the floating-point number in the `ParameterExpression` is changing by 1 ULP, which sounds suspiciously like there might be a dodgy float-to-str conversion happening somewhere in the `ParameterExpression` handling in QPY?

I mean, the underlying cause is another of these things where fully assigned `ParameterExpression`s not immediately becoming regular floats is causing problems.  But either way, if it happens with bound `ParameterExpression`, I'm sure there'll be mechanisms to trigger it with bound ones as well, we just don't notice as much if they're like 1ULP differences in a random float coefficient in a mostly symbolic calculation.

See Qiskit/qiskit-ibm-runtime#666.

cc: @kt474, @nbronn.


Discussion:
Investigated. This is the issue of `ParameterExpression` roundtrip as you mentioned, which is happening here.

https://github.com/Qiskit/qiskit-terra/blob/6829bb18cf791960896fe72b9be9611aac44155a/qiskit/qpy/binary_io/value.py#L50

repr of `obj. _symbol_expr` is -1.71319829743142 but `float(obj. _symbol_expr)` is -1.7131982974314244. Likely sympy `srepr` command (we still rely on sympy for serialization) takes repr of symbolic expression which likely has smaller significant digit. 

My [old PR](https://github.com/Qiskit/qiskit-terra/pull/8232) could address this issue if symengine is available in your environement. Basically symengine expression can be directly serialized without making plain text, i.e. `obj.__reduce__()[1][0]`.


(Edit) The PR above has dependency on the next release of symengine, which has not been released for a while.
Likely sympy `sympify` reduces the digit, not `srepr`. i.e.

```python
from sympy import simplify

qc2_assigned = qc2.assign_parameters(param_bind)
expr = expr = qc2_assigned.data[0][0].params[0]

>>> float(expr._symbol_expr), float(simplify(expr._symbol_expr))
(-1.7131982974314244, -1.71319829743142)
```

Here `simplify` is used to typecast symengine Expression to sympy Expression to use `srepr`.
Unfortunately looks like your PR's still on hold, since the necessary Symengine fix is merged to main, but they haven't cut a 0.10.0 release.  Other than that, though, yeah, your PR looks like a much more straightforwards way to go than serialisation to string.
Thanks for your comment on my PR. As you pointed out, my PR would break portability of QPY data. We need another mechanism to serialize expression without changing significant digits, or we should promote symengine to be required. Alternatively we could use single precision floating point in circuit calibrations dictionary.
Python doesn't natively support single-precision floats, so I suppose what we'd really be saying is that we round the stored data in `QuantumCircuit.calibrations` off to a certain number of mantissa digits.   That feels a bit gross to do it at that level of round-off, because there'd be cases where certain parameters being 1ULP off each other would need separate calibrations, while others that were $2^{28}$ ULP off will compare equal.

Making the calibration dict fuzzy in any way just to support deficiencies in parameter serialisation via QPY is probably not going to be very satisfying.  I wonder if we could accept a performance hit in QPY where we verify on write-out that the roundtrip is exactly equal, and revert to some slower-but-safer serialisation method if it isn't?  I'm not very sure what that would be, though.
Hmm me neither. Probably we can typecast expression to float before calling sympy `simplify`. 

```python
from qiskit.circuit import Parameter

p = Parameter("p")
v = -1.7131982974314244

p_assigned = p.assign(p, v)

float(p_assigned) == p_assigned  # True
```

I think this doesn't break any reference.

(edit) but we cannot recover digits of coefficients in unbound expression. this could be an issue for QASM3 with offloaded parameter binding?
I found that it's a bug of symengine. See https://github.com/symengine/symengine/issues/1901. It rounds float value with wrong digits, which may affect translation of symengine expression to sympy expression, which is necessary for serialization in QPY. Note that this issue has been already fixed in their master branch but just not released. I tried to install their master branch but it was really complicated and I gave up. I think we just need to wait for symengine 0.10.
For temp fix you can uninstall symengine from your env. This allows you to bypass the expression typecast at the price of performance.
fyi https://github.com/symengine/symengine.py/issues/433

Resolving patch:
diff --git a/qiskit/circuit/quantumcircuit.py b/qiskit/circuit/quantumcircuit.py
--- a/qiskit/circuit/quantumcircuit.py
+++ b/qiskit/circuit/quantumcircuit.py
@@ -2854,7 +2854,17 @@ def _assign_parameter(self, parameter: Parameter, value: ParameterValueType) ->
                     new_param = assignee.assign(parameter, value)
                     # if fully bound, validate
                     if len(new_param.parameters) == 0:
-                        instr.params[param_index] = instr.validate_parameter(new_param)
+                        if new_param._symbol_expr.is_integer and new_param.is_real():
+                            val = int(new_param)
+                        elif new_param.is_real():
+                            # Workaround symengine not supporting float(<ComplexDouble>)
+                            val = complex(new_param).real
+                        else:
+                            # complex values may no longer be supported but we
+                            # defer raising an exception to validdate_parameter
+                            # below for now.
+                            val = complex(new_param)
+                        instr.params[param_index] = instr.validate_parameter(val)
                     else:
                         instr.params[param_index] = new_param
 
@@ -2911,7 +2921,11 @@ def _assign_calibration_parameters(
                         if isinstance(p, ParameterExpression) and parameter in p.parameters:
                             new_param = p.assign(parameter, value)
                             if not new_param.parameters:
-                                new_param = float(new_param)
+                                if new_param._symbol_expr.is_integer:
+                                    new_param = int(new_param)
+                                else:
+                                    # Workaround symengine not supporting float(<ComplexDouble>)
+                                    new_param = complex(new_param).real
                             new_cal_params.append(new_param)
                         else:
                             new_cal_params.append(p)


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
@@ -19,7 +19,7 @@
 
 import numpy as np
 
-from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
+from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister, pulse
 from qiskit.circuit import CASE_DEFAULT
 from qiskit.circuit.classicalregister import Clbit
 from qiskit.circuit.quantumregister import Qubit
@@ -274,6 +274,39 @@ def test_bound_parameter(self):
         self.assertEqual(qc, new_circ)
         self.assertDeprecatedBitProperties(qc, new_circ)
 
+    def test_bound_calibration_parameter(self):
+        """Test a circuit with a bound calibration parameter is correctly serialized.
+
+        In particular, this test ensures that parameters on a circuit
+        instruction are consistent with the circuit's calibrations dictionary
+        after serialization.
+        """
+        amp = Parameter("amp")
+
+        with pulse.builder.build() as sched:
+            pulse.builder.play(pulse.Constant(100, amp), pulse.DriveChannel(0))
+
+        gate = Gate("custom", 1, [amp])
+
+        qc = QuantumCircuit(1)
+        qc.append(gate, (0,))
+        qc.add_calibration(gate, (0,), sched)
+        qc.assign_parameters({amp: 1 / 3}, inplace=True)
+
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circ = load(qpy_file)[0]
+        self.assertEqual(qc, new_circ)
+        instruction = new_circ.data[0]
+        cal_key = (
+            tuple(new_circ.find_bit(q).index for q in instruction.qubits),
+            tuple(instruction.operation.params),
+        )
+        # Make sure that looking for a calibration based on the instruction's
+        # parameters succeeds
+        self.assertIn(cal_key, new_circ.calibrations[gate.name])
+
     def test_parameter_expression(self):
         """Test a circuit with a parameter expression."""
         theta = Parameter("theta")
diff --git a/test/python/circuit/test_parameters.py b/test/python/circuit/test_parameters.py
--- a/test/python/circuit/test_parameters.py
+++ b/test/python/circuit/test_parameters.py
@@ -21,7 +21,7 @@
 from test import combine
 
 import numpy
-from ddt import data, ddt
+from ddt import data, ddt, named_data
 
 import qiskit
 import qiskit.circuit.library as circlib
@@ -346,6 +346,23 @@ def test_multiple_named_parameters(self):
         self.assertEqual(theta.name, "θ")
         self.assertEqual(qc.parameters, {theta, x})
 
+    @named_data(
+        ["int", 2, int],
+        ["float", 2.5, float],
+        ["float16", numpy.float16(2.5), float],
+        ["float32", numpy.float32(2.5), float],
+        ["float64", numpy.float64(2.5), float],
+    )
+    def test_circuit_assignment_to_numeric(self, value, type_):
+        """Test binding a numeric value to a circuit instruction"""
+        x = Parameter("x")
+        qc = QuantumCircuit(1)
+        qc.append(Instruction("inst", 1, 0, [x]), (0,))
+        qc.assign_parameters({x: value}, inplace=True)
+        bound = qc.data[0].operation.params[0]
+        self.assertIsInstance(bound, type_)
+        self.assertEqual(bound, value)
+
     def test_partial_binding(self):
         """Test that binding a subset of circuit parameters returns a new parameterized circuit."""
         theta = Parameter("θ")
@@ -401,10 +418,10 @@ def test_expression_partial_binding(self):
                 self.assertTrue(isinstance(pqc.data[0].operation.params[0], ParameterExpression))
                 self.assertEqual(str(pqc.data[0].operation.params[0]), "phi + 2")
 
-                fbqc = getattr(pqc, assign_fun)({phi: 1})
+                fbqc = getattr(pqc, assign_fun)({phi: 1.0})
 
                 self.assertEqual(fbqc.parameters, set())
-                self.assertTrue(isinstance(fbqc.data[0].operation.params[0], ParameterExpression))
+                self.assertIsInstance(fbqc.data[0].operation.params[0], float)
                 self.assertEqual(float(fbqc.data[0].operation.params[0]), 3)
 
     def test_two_parameter_expression_binding(self):
@@ -448,7 +465,7 @@ def test_expression_partial_binding_zero(self):
                 fbqc = getattr(pqc, assign_fun)({phi: 1})
 
                 self.assertEqual(fbqc.parameters, set())
-                self.assertTrue(isinstance(fbqc.data[0].operation.params[0], ParameterExpression))
+                self.assertIsInstance(fbqc.data[0].operation.params[0], int)
                 self.assertEqual(float(fbqc.data[0].operation.params[0]), 0)
 
     def test_raise_if_assigning_params_not_in_circuit(self):
@@ -505,8 +522,15 @@ def test_calibration_assignment(self):
         circ.add_calibration("rxt", [0], rxt_q0, [theta])
         circ = circ.assign_parameters({theta: 3.14})
 
-        self.assertTrue(((0,), (3.14,)) in circ.calibrations["rxt"])
-        sched = circ.calibrations["rxt"][((0,), (3.14,))]
+        instruction = circ.data[0]
+        cal_key = (
+            tuple(circ.find_bit(q).index for q in instruction.qubits),
+            tuple(instruction.operation.params),
+        )
+        self.assertEqual(cal_key, ((0,), (3.14,)))
+        # Make sure that key from instruction data matches the calibrations dictionary
+        self.assertIn(cal_key, circ.calibrations["rxt"])
+        sched = circ.calibrations["rxt"][cal_key]
         self.assertEqual(sched.instructions[0][1].pulse.amp, 0.2)
 
     def test_calibration_assignment_doesnt_mutate(self):
@@ -531,11 +555,11 @@ def test_calibration_assignment_doesnt_mutate(self):
         self.assertNotEqual(assigned_circ.calibrations, circ.calibrations)
 
     def test_calibration_assignment_w_expressions(self):
-        """That calibrations with multiple parameters and more expressions."""
+        """That calibrations with multiple parameters are assigned correctly"""
         theta = Parameter("theta")
         sigma = Parameter("sigma")
         circ = QuantumCircuit(3, 3)
-        circ.append(Gate("rxt", 1, [theta, sigma]), [0])
+        circ.append(Gate("rxt", 1, [theta / 2, sigma]), [0])
         circ.measure(0, 0)
 
         rxt_q0 = pulse.Schedule(
@@ -548,8 +572,15 @@ def test_calibration_assignment_w_expressions(self):
         circ.add_calibration("rxt", [0], rxt_q0, [theta / 2, sigma])
         circ = circ.assign_parameters({theta: 3.14, sigma: 4})
 
-        self.assertTrue(((0,), (3.14 / 2, 4)) in circ.calibrations["rxt"])
-        sched = circ.calibrations["rxt"][((0,), (3.14 / 2, 4))]
+        instruction = circ.data[0]
+        cal_key = (
+            tuple(circ.find_bit(q).index for q in instruction.qubits),
+            tuple(instruction.operation.params),
+        )
+        self.assertEqual(cal_key, ((0,), (3.14 / 2, 4)))
+        # Make sure that key from instruction data matches the calibrations dictionary
+        self.assertIn(cal_key, circ.calibrations["rxt"])
+        sched = circ.calibrations["rxt"][cal_key]
         self.assertEqual(sched.instructions[0][1].pulse.amp, 0.2)
         self.assertEqual(sched.instructions[0][1].pulse.sigma, 16)
 
@@ -789,7 +820,7 @@ def test_binding_parameterized_circuits_built_in_multiproc(self):
         for qc in results:
             circuit.compose(qc, inplace=True)
 
-        parameter_values = [{x: 1 for x in parameters}]
+        parameter_values = [{x: 1.0 for x in parameters}]
 
         qobj = assemble(
             circuit,
@@ -802,7 +833,7 @@ def test_binding_parameterized_circuits_built_in_multiproc(self):
         self.assertTrue(
             all(
                 len(inst.params) == 1
-                and isinstance(inst.params[0], ParameterExpression)
+                and isinstance(inst.params[0], float)
                 and float(inst.params[0]) == 1
                 for inst in qobj.experiments[0].instructions
             )
diff --git a/test/python/qasm3/test_export.py b/test/python/qasm3/test_export.py
--- a/test/python/qasm3/test_export.py
+++ b/test/python/qasm3/test_export.py
@@ -469,7 +469,7 @@ def test_reused_custom_parameter(self):
                 "  rx(0.5) _gate_q_0;",
                 "}",
                 f"gate {circuit_name_1} _gate_q_0 {{",
-                "  rx(1) _gate_q_0;",
+                "  rx(1.0) _gate_q_0;",
                 "}",
                 "qubit[1] _all_qubits;",
                 "let q = _all_qubits[0:0];",