Repository: Qiskit/qiskit (python)

Issue:
Add instructions for building documentation locally for documentation related contributions
### What should we add?

I can't find instructions for building documentation locally for qiskit-terra in https://qiskit.org/documentation/contributing_to_qiskit.html#contributing-to-documentation nor https://github.com/Qiskit/qiskit-terra/blob/main/CONTRIBUTING.md I think it would to add something similar to https://github.com/Qiskit/qiskit-machine-learning/issues/413 for qiskit-terra so that community contributors can follow to make documentation related contributions

Pull request:
Documentation on building doc locally
<!--
⚠️ 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
In Contributing doc, I have added section for building documentation locally using tox, sphinx-build, and make html.


### Details and comments
fix #8274



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 84259495d..83cff0a2c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,6 +16,7 @@ community in this goal.
 * [Release notes](#release-notes)
 * [Testing](#testing)
 * [Style and Lint](#style-and-lint)
+* [Building API docs locally](#building-api-docs-locally)
 * [Development Cycle](#development-cycle)
   * [Branches](#branches)
   * [Release Cycle](#release-cycle)
@@ -388,7 +389,6 @@ like for the current state of the repo you can run: `tox -edocs` which will
 build all the documentation into `docs/_build/html` and the release notes in
 particular will be located at `docs/_build/html/release_notes.html`
 
-
 ## Testing
 
 Once you've made a code change, it is important to verify that your change
@@ -580,6 +580,22 @@ rather than via `tox`. If you have installed the development packages in your py
 `pip install -r requirements-dev.txt`, then `ruff` and `black` will be available and can be run from
 the command line. See [`tox.ini`](tox.ini) for how `tox` invokes them.
 
+## Building API docs locally
+
+If you have made changes to the API documentation, you can run the command below
+to build documentation locally to review the html output. 
+The easiest and recommended way to build the documentation is to use [**tox**](https://tox.readthedocs.io/en/latest/#):
+
+```
+tox -edocs
+```
+
+Once you run this command, the output will be located at `docs/_build/html`.
+Then, open up the file `index.html` in your browser.
+
+Sometimes Sphinx can get in a bad cache state. Run `tox -e docs-clean`
+to reset Sphinx's cache.
+
 ## Development cycle
 
 The development cycle for qiskit is all handled in the open using
diff --git a/qiskit/circuit/library/n_local/pauli_two_design.py b/qiskit/circuit/library/n_local/pauli_two_design.py
index fa63c8c6f..b79f08889 100644
--- a/qiskit/circuit/library/n_local/pauli_two_design.py
+++ b/qiskit/circuit/library/n_local/pauli_two_design.py
@@ -75,6 +75,16 @@ class PauliTwoDesign(TwoLocal):
         insert_barriers: bool = False,
         name: str = "PauliTwoDesign",
     ):
+        """
+        Args:
+            num_qubits: The number of qubits of the Pauli Two-Design circuit.
+            reps: Specifies how often a block consisting of a rotation layer and entanglement
+                layer is repeated.
+            seed: The seed for randomly choosing the axes of the Pauli rotations.
+            insert_barriers: If ``True``, barriers are inserted in between each layer. If ``False``,
+                no barriers are inserted. Defaults to ``False``.
+
+        """
         from qiskit.circuit.library import RYGate  # pylint: disable=cyclic-import
 
         # store a random number generator
diff --git a/qiskit/circuit/parameterexpression.py b/qiskit/circuit/parameterexpression.py
index 237d6e9d8..149fae2b4 100644
--- a/qiskit/circuit/parameterexpression.py
+++ b/qiskit/circuit/parameterexpression.py
@@ -495,6 +495,52 @@ class ParameterExpression:
             return False
         return symbol_expr.is_real
 
+    def numeric(self) -> int | float | complex:
+        """Return a Python number representing this object, using the most restrictive of
+        :class:`int`, :class:`float` and :class:`complex` that is valid for this object.
+
+        In general, an :class:`int` is only returned if the expression only involved symbolic
+        integers.  If floating-point values were used during the evaluation, the return value will
+        be a :class:`float` regardless of whether the represented value is an integer.  This is
+        because floating-point values "infect" symbolic computations by their inexact nature, and
+        symbolic libraries will use inexact floating-point semantics not exact real-number semantics
+        when they are involved.  If you want to assert that all floating-point calculations *were*
+        carried out at infinite precision (i.e. :class:`float` could represent every intermediate
+        value exactly), you can use :meth:`float.is_integer` to check if the return float represents
+        an integer and cast it using :class:`int` if so.  This would be an unusual pattern;
+        typically one requires this by only ever using explicitly :class:`~numbers.Rational` objects
+        while working with symbolic expressions.
+
+        This is more reliable and performant than using :meth:`is_real` followed by calling
+        :class:`float` or :class:`complex`, as in some cases :meth:`is_real` needs to force a
+        floating-point evaluation to determine an accurate result to work around bugs in the
+        upstream symbolic libraries.
+
+        Returns:
+            A Python number representing the object.
+
+        Raises:
+            TypeError: if there are unbound parameters.
+        """
+        if self._parameter_symbols:
+            raise TypeError(
+                f"Expression with unbound parameters '{self.parameters}' is not numeric"
+            )
+        if self._symbol_expr.is_integer:
+            # Integer evaluation is reliable, as far as we know.
+            return int(self._symbol_expr)
+        # We've come across several ways in which symengine's general-purpose evaluators
+        # introduce spurious imaginary components can get involved in the output.  The most
+        # reliable strategy "try it and see" while forcing real floating-point evaluation.
+        try:
+            real_expr = self._symbol_expr.evalf(real=True)
+        except RuntimeError:
+            # Symengine returns `complex` if any imaginary floating-point enters at all, even if
+            # the result is zero.  The best we can do is detect that and decay to a float.
+            out = complex(self._symbol_expr)
+            return out.real if out.imag == 0.0 else out
+        return float(real_expr)
+
     def sympify(self):
         """Return symbolic expression as a raw Sympy or Symengine object.
 
diff --git a/qiskit/circuit/quantumcircuit.py b/qiskit/circuit/quantumcircuit.py
index ae8d17160..91cfac5c4 100644
--- a/qiskit/circuit/quantumcircuit.py
+++ b/qiskit/circuit/quantumcircuit.py
@@ -3081,15 +3081,7 @@ class QuantumCircuit:
                             target._parameter_table[parameter] = ParameterReferences(())
                         target._parameter_table[parameter].add((operation, index))
                     if not new_parameter.parameters:
-                        if new_parameter.is_real():
-                            new_parameter = (
-                                int(new_parameter)
-                                if new_parameter._symbol_expr.is_integer
-                                else float(new_parameter)
-                            )
-                        else:
-                            new_parameter = complex(new_parameter)
-                        new_parameter = operation.validate_parameter(new_parameter)
+                        new_parameter = operation.validate_parameter(new_parameter.numeric())
                 elif isinstance(assignee, QuantumCircuit):
                     new_parameter = assignee.assign_parameters(
                         {to_bind: bound_value}, inplace=False, flat_input=True
@@ -3130,9 +3122,9 @@ class QuantumCircuit:
                 for to_bind in contained:
                     parameter = parameter.assign(to_bind, parameter_binds.mapping[to_bind])
                 if not parameter.parameters:
-                    parameter = (
-                        int(parameter) if parameter._symbol_expr.is_integer else float(parameter)
-                    )
+                    parameter = parameter.numeric()
+                    if isinstance(parameter, complex):
+                        raise TypeError(f"Calibration cannot use complex number: '{parameter}'")
                 new_parameters[i] = parameter
                 modified = True
             if modified:
diff --git a/qiskit/transpiler/passes/basis/basis_translator.py b/qiskit/transpiler/passes/basis/basis_translator.py
index aed57ee34..74e564ccd 100644
--- a/qiskit/transpiler/passes/basis/basis_translator.py
+++ b/qiskit/transpiler/passes/basis/basis_translator.py
@@ -319,16 +319,8 @@ class BasisTranslator(TransformationPass):
                                     new_value = new_value.assign(*x)
                             else:
                                 new_value = param.bind(bind_dict)
-                            # cast from ParameterExpression to number, if no parameters left
                             if not new_value.parameters:
-                                if new_value.is_real():
-                                    new_value = (
-                                        int(new_value)
-                                        if new_value._symbol_expr.is_integer
-                                        else float(new_value)
-                                    )
-                                else:
-                                    new_value = complex(new_value)
+                                new_value = new_value.numeric()
                             new_params.append(new_value)
                     new_op.params = new_params
                 else:
@@ -345,26 +337,9 @@ class BasisTranslator(TransformationPass):
                 else:
                     new_phase = old_phase.bind(bind_dict)
                 if not new_phase.parameters:
-                    if new_phase.is_real():
-                        new_phase = (
-                            int(new_phase)
-                            if new_phase._symbol_expr.is_integer
-                            else float(new_phase)
-                        )
-                    else:
-                        # If is_real() evals false try casting to a float
-                        # anyway in case there is a rounding error adding
-                        # a near 0 complex term
-                        try:
-                            new_phase = float(new_phase)
-                        except TypeError as exc:
-                            raise TranspilerError(
-                                f"Global phase: {new_phase} is complex which is invalid"
-                            ) from exc
-                try:
-                    new_phase = float(new_phase)
-                except TypeError:
-                    pass
+                    new_phase = new_phase.numeric()
+                    if isinstance(new_phase, complex):
+                        raise TranspilerError(f"Global phase must be real, but got '{new_phase}'")
                 bound_target_dag.global_phase = new_phase
         else:
             bound_target_dag = target_dag
diff --git a/releasenotes/notes/parameterexpression.numeric-958d365dadabfb81.yaml b/releasenotes/notes/parameterexpression.numeric-958d365dadabfb81.yaml
new file mode 100644
index 000000000..babdf8325
--- /dev/null
+++ b/releasenotes/notes/parameterexpression.numeric-958d365dadabfb81.yaml
@@ -0,0 +1,10 @@
+---
+features:
+  - |
+    A new method :meth:`.ParameterExpression.numeric` is added, which converts a fully bound
+    parameter expression into the most restrictive builtin Python numeric type that accurately
+    describes the result of the symbolic evaluation.  For example, a symbolic integer will become an
+    :class:`int`, while a symbolic real number will become a :class:`float` and a complex number
+    will become a :class:`complex`.  This method includes several workarounds for peculiarities of
+    the evaluation contexts of ``symengine``, which can sometimes lead to spurious results when
+    calling :class:`complex` or :class:`float` on an expression directly.


Test patch:
diff --git a/test/python/circuit/test_parameters.py b/test/python/circuit/test_parameters.py
index 6a5f780a9..78b67c6bc 100644
--- a/test/python/circuit/test_parameters.py
+++ b/test/python/circuit/test_parameters.py
@@ -2005,6 +2005,32 @@ class TestParameterExpressions(QiskitTestCase):
         bound = x.bind({x: 1 + 1j})
         self.assertEqual(bound.is_real(), False)
 
+    def test_numeric(self):
+        """Tests of the 'numeric' method."""
+        a, b = Parameter("a"), Parameter("b")
+        one_int = (1 + a).assign(a, 0)
+        self.assertIsInstance(one_int.numeric(), int)
+        self.assertEqual(one_int.numeric(), 1)
+        one_float = (1.0 + a).assign(a, 0.0)
+        self.assertIsInstance(one_float.numeric(), float)
+        self.assertEqual(one_float.numeric(), 1.0)
+        one_imaginary = (1j + a).assign(a, 0.0)
+        self.assertIsInstance(one_imaginary.numeric(), complex)
+        self.assertEqual(one_imaginary.numeric(), 1j)
+
+        # This is one particular case where symengine 0.9.2 (and probably others) struggles when
+        # evaluating in the complex domain, but gets the right answer if forced to the real domain.
+        # It appears more commonly because `symengine.Basic.subs` does not simplify the expression
+        # tree eagerly, so the `_symbol_expr` is `0.5 * (0.5)**2`.  Older symengines then introduce
+        # a spurious small imaginary component when evaluating this `Mul(x, Pow(y, z))` pattern in
+        # the complex domain.
+        problem = (0.5 * a * b).assign(b, 0.5).assign(a, 0.5)
+        self.assertIsInstance(problem.numeric(), float)
+        self.assertEqual(problem.numeric(), 0.125)
+
+        with self.assertRaisesRegex(TypeError, "unbound parameters"):
+            (a + b).numeric()
+
 
 class TestParameterEquality(QiskitTestCase):
     """Test equality of Parameters and ParameterExpressions."""