Repository: Qiskit/qiskit — pull request #14714 (python)
Title: ObservablesArray fixes: stop over-identification as non-Hermitian, raise for empty observables

### Summary\r\n\r\nFix #14567.\r\nFix #14614.\r\n\r\n\r\n### Details and comments\r\n\r\nTo the reviewer: please pay extra care to review the decisions (see details in the issues):\r\n- Empty observables are not allowed in `ObservablesArray`.\r\n- An extra call to `SparseObservable.simplify` in `ObservablesArray.coerce`..\r\n\r\n

Resolved issues:
title: `EstimatorPub.validate` throws a `StopIteration` when called with a `SparsePauliOp` having nil coefficients
body: ### Environment\n\n- **Qiskit version**: 2.2.0 (Couldn't replicate with 1.4.3 or 2.0.2)\n- **Python version**: 3.13.3\n- **Operating system**: Arch Linux\n\n\n### What is happening?\n\nOk, this one's kinda fun. When passing a `SparsePauliOp` having nil coefficients to `EstimatorPub.validate`, it throws a `StopIteration`. If you wonder why on earth would you use such operators, they may come up in the computations of some gradients.\n\n### How can we reproduce the issue?\n\n```python\nfrom qiskit import QuantumCircuit\nfrom qiskit.quantum_info import SparsePauliOp\nfrom qiskit.primitives import StatevectorEstimator\n\nop = SparsePauliOp(["Z"], [0.])\nqc = QuantumCircuit(1)\nStatevectorEstimator().run([(qc, op)])\n```\n\n### What should happen?\n\nIt should be possible to run jobs on operators with nil coefficients.\n\n### Any suggestions?\n\n_No response_

Hints:

Fix patch:
diff --git a/qiskit/primitives/containers/observables_array.py b/qiskit/primitives/containers/observables_array.py\nindex bc2092b321f3..6a9b05148050 100644\n--- a/qiskit/primitives/containers/observables_array.py\n+++ b/qiskit/primitives/containers/observables_array.py\n@@ -106,7 +106,7 @@ def __init__(\n \n     @staticmethod\n     def _obs_to_dict(obs: SparseObservable) -> Mapping[str, float]:\n-        """Convert a sparse observable to a mapping from Pauli strings to coefficients."""\n+        """Convert a simplified sparse observable to a mapping from Pauli strings to coefficients."""\n         result = {}\n         for sparse_pauli_str, pauli_qubits, coeff in obs.to_sparse_list():\n \n@@ -260,7 +260,7 @@ def coerce_observable(cls, observable: ObservableLike) -> SparseObservable:\n \n         Raises:\n             TypeError: If the input cannot be formatted because its type is not valid.\n-            ValueError: If the input observable is invalid.\n+            ValueError: If the input observable is invalid or empty.\n         """\n         # Pauli-type conversions\n         if isinstance(observable, SparsePauliOp):\n@@ -282,21 +282,28 @@ def coerce_observable(cls, observable: ObservableLike) -> SparseObservable:\n             observable = SparseObservable.from_list(term_list)\n \n         if isinstance(observable, SparseObservable):\n-            # Check that the operator has real coeffs\n+            observable = observable.simplify()\n+\n+            # Check that the simplified operator has real coeffs\n             coeffs = np.real_if_close(observable.coeffs)\n             if np.iscomplexobj(coeffs):\n                 raise ValueError(\n-                    "Non-Hermitian input observable: the input SparsePauliOp has non-zero"\n+                    "Non-Hermitian input observable: the simplified input observable has a non-zero"\n                     " imaginary part in its coefficients."\n                 )\n \n-            return SparseObservable.from_raw_parts(\n+            observable = SparseObservable.from_raw_parts(\n                 observable.num_qubits,\n                 coeffs,\n                 observable.bit_terms,\n                 observable.indices,\n                 observable.boundaries,\n-            ).simplify(tol=0)\n+            )\n+\n+            if observable == SparseObservable.zero(observable.num_qubits):\n+                raise ValueError("Empty observable was detected.")\n+\n+            return observable\n \n         raise TypeError(f"Invalid observable type: {type(observable)}")\n \ndiff --git a/releasenotes/notes/obs-array-small-fixes-2947927d2eccc633.yaml b/releasenotes/notes/obs-array-small-fixes-2947927d2eccc633.yaml\nnew file mode 100644\nindex 000000000000..21885127b647\n--- /dev/null\n+++ b/releasenotes/notes/obs-array-small-fixes-2947927d2eccc633.yaml\n@@ -0,0 +1,5 @@\n+---\n+fixes:\n+  - |\n+    Fixed a bug in :meth:`.ObservablesArray.coerce_observable` where an exception that an observable was not Hermitian was over-triggered. \n+    Observables equal to ``zero`` are not allowed and now invoke a graceful failure.\n\\ No newline at end of file\n

Test patch:
diff --git a/test/python/primitives/containers/test_observables_array.py b/test/python/primitives/containers/test_observables_array.py\nindex 808f7925471f..e7836296d6aa 100644\n--- a/test/python/primitives/containers/test_observables_array.py\n+++ b/test/python/primitives/containers/test_observables_array.py\n@@ -626,6 +626,17 @@ def test_sparse_observables_array(self):\n         self.assertEqual(arr2, arr)\n         self.assertEqual(id(arr2), id(arr))\n \n+    def test_empty_observable(self):\n+        """Verify that empty observables are not allowed"""\n+        with self.assertRaisesRegex(ValueError, "Empty observable"):\n+            ObservablesArray([{"Z": 0}])\n+\n+    def test_hermitian_after_simplification(self):\n+        """Verify that no error is raised if observables contain complex coefficients\n+        that get cancelled during simplification"""\n+        obs = qi.SparseObservable.from_list([("Z", 1j), ("Z", -1j), ("X", 1)])\n+        ObservablesArray(obs)\n+\n     def test_invalid_basis_type_raises_type_error(self):\n         """Test that invalid basis type raises TypeError"""\n         invalid_basis = {1: "value", 2: "another_value"}  # Invalid keys (integers)\n