Repository: Qiskit/qiskit

Issue:
Pulse parameter assignment bug
### Environment

- **Qiskit version**: 1.0
- **Python version**: 3.11 
- **Operating system**: MacOS

### What is happening?

Build of Qiskit Dynamics tutorial fails due to a bug in Qiskit.
https://github.com/Qiskit-Extensions/qiskit-dynamics/actions/runs/8194001990/job/22409038254

This tutorial runs `CrossResonanceHamiltonian` experiment that creates circuits with different pulse durations. `ScheduleBlock` with a parameterized duration is attached to the circuit as a calibration, and circuits are created by assigning different duration values via `assign_parameters` method.

This method internally calls `ParameterSetter` in the pulse module,
https://github.com/Qiskit/qiskit/blob/8b6e4fe0fe55fd96e182e526860321db702a7a54/qiskit/pulse/parameter_manager.py#L224-L231
and the assigned parameter expression is evaluated by the formatter function to assemble PulseQobj.
https://github.com/Qiskit/qiskit/blob/8b6e4fe0fe55fd96e182e526860321db702a7a54/qiskit/pulse/utils.py#L42-L45

However, when we try to evaluate a particular integer number, it is unintentionally typecasted into float. Since pulse duration must be an integer value, the assigned `ScheduleBlock` raises an error and crashes the experiment run.

However, this function was written roughly 3 years ago, and remains almost unchanged. I'm not sure why the docs build started to fail now :(

### How can we reproduce the issue?

Minimum code 
```python
from qiskit.circuit import Parameter
from qiskit.pulse.utils import format_parameter_value

input_value = 15482812500000
param = Parameter("P1")
expr = param.assign(param, input_value)

format_parameter_value(expr)
```
This returns `15482812499999.998`.

### What should happen?

Integer value should be evaluated as an integer.

### Any suggestions?

_No response_


Discussion:

Resolving patch:
diff --git a/qiskit/pulse/utils.py b/qiskit/pulse/utils.py
--- a/qiskit/pulse/utils.py
+++ b/qiskit/pulse/utils.py
@@ -52,17 +52,29 @@ def format_parameter_value(
     Returns:
         Value casted to non-parameter data type, when possible.
     """
-    try:
-        # value is assigned.
-        # note that ParameterExpression directly supports __complex__ via sympy or symengine
-        evaluated = complex(operand)
-        # remove truncation error
-        evaluated = np.round(evaluated, decimals=decimal)
-        # typecast into most likely data type
-        if np.isreal(evaluated):
-            evaluated = float(evaluated.real)
-            if evaluated.is_integer():
-                evaluated = int(evaluated)
+    if isinstance(operand, ParameterExpression):
+        try:
+            operand = operand.numeric()
+        except TypeError:
+            # Unassigned expression
+            return operand
+
+    # Return integer before calling the numpy round function.
+    # The input value is multiplied by 10**decimals, rounds to an integer
+    # and divided by 10**decimals. For a large enough integer,
+    # this operation may introduce a rounding error in the float operations
+    # and accidentally returns a float number.
+    if isinstance(operand, int):
+        return operand
+
+    # Remove truncation error and convert the result into Python builtin type.
+    # Value could originally contain a rounding error, e.g. 1.00000000001
+    # which may occur during the parameter expression evaluation.
+    evaluated = np.round(operand, decimals=decimal).item()
+
+    if isinstance(evaluated, complex):
+        if np.isclose(evaluated.imag, 0.0):
+            evaluated = evaluated.real
         else:
             warnings.warn(
                 "Assignment of complex values to ParameterExpression in Qiskit Pulse objects is "
@@ -76,13 +88,11 @@ def format_parameter_value(
                 "converted in a similar fashion to avoid the use of complex parameters.",
                 PendingDeprecationWarning,
             )
-
-        return evaluated
-    except TypeError:
-        # value is not assigned
-        pass
-
-    return operand
+            return evaluated
+    # Type cast integer-like float into Python builtin integer, after rounding.
+    if evaluated.is_integer():
+        return int(evaluated)
+    return evaluated
 
 
 def instruction_duration_validation(duration: int):


Test patch:
diff --git a/test/python/pulse/test_parameter_manager.py b/test/python/pulse/test_parameter_manager.py
--- a/test/python/pulse/test_parameter_manager.py
+++ b/test/python/pulse/test_parameter_manager.py
@@ -17,6 +17,7 @@
 from copy import deepcopy
 from unittest.mock import patch
 
+import ddt
 import numpy as np
 
 from qiskit import pulse
@@ -24,6 +25,7 @@
 from qiskit.pulse.exceptions import PulseError, UnassignedDurationError
 from qiskit.pulse.parameter_manager import ParameterGetter, ParameterSetter
 from qiskit.pulse.transforms import AlignEquispaced, AlignLeft, inline_subroutines
+from qiskit.pulse.utils import format_parameter_value
 from test import QiskitTestCase  # pylint: disable=wrong-import-order
 
 
@@ -557,3 +559,80 @@ def test_cannot_build_schedule_with_unassigned_duration(self):
         sched = pulse.Schedule()
         with self.assertRaises(UnassignedDurationError):
             sched.insert(0, test_play)
+
+
+@ddt.ddt
+class TestFormatParameter(QiskitTestCase):
+    """Test format_parameter_value function."""
+
+    def test_format_unassigned(self):
+        """Format unassigned parameter expression."""
+        p1 = Parameter("P1")
+        p2 = Parameter("P2")
+        expr = p1 + p2
+
+        self.assertEqual(format_parameter_value(expr), expr)
+
+    def test_partly_unassigned(self):
+        """Format partly assigned parameter expression."""
+        p1 = Parameter("P1")
+        p2 = Parameter("P2")
+        expr = (p1 + p2).assign(p1, 3.0)
+
+        self.assertEqual(format_parameter_value(expr), expr)
+
+    @ddt.data(1, 1.0, 1.00000000001, np.int64(1))
+    def test_integer(self, value):
+        """Format integer parameter expression."""
+        p1 = Parameter("P1")
+        expr = p1.assign(p1, value)
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, int)
+        self.assertEqual(out, 1)
+
+    @ddt.data(1.2, np.float64(1.2))
+    def test_float(self, value):
+        """Format float parameter expression."""
+        p1 = Parameter("P1")
+        expr = p1.assign(p1, value)
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, float)
+        self.assertEqual(out, 1.2)
+
+    @ddt.data(1.2 + 3.4j, np.complex128(1.2 + 3.4j))
+    def test_complex(self, value):
+        """Format float parameter expression."""
+        p1 = Parameter("P1")
+        expr = p1.assign(p1, value)
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, complex)
+        self.assertEqual(out, 1.2 + 3.4j)
+
+    def test_complex_rounding_error(self):
+        """Format float parameter expression."""
+        p1 = Parameter("P1")
+        expr = p1.assign(p1, 1.2 + 1j * 1e-20)
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, float)
+        self.assertEqual(out, 1.2)
+
+    def test_builtin_float(self):
+        """Format float parameter expression."""
+        expr = 1.23
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, float)
+        self.assertEqual(out, 1.23)
+
+    @ddt.data(15482812500000, 8465625000000, 4255312500000)
+    def test_edge_case(self, edge_case_val):
+        """Format integer parameter expression with
+        a particular integer number that causes rounding error at typecast."""
+
+        # Numbers to be tested here are chosen randomly.
+        # These numbers had caused mis-typecast into float before qiskit/#11972.
+
+        p1 = Parameter("P1")
+        expr = p1.assign(p1, edge_case_val)
+        out = format_parameter_value(expr)
+        self.assertIsInstance(out, int)
+        self.assertEqual(out, edge_case_val)