• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Qiskit / qiskit / 14907095122

08 May 2025 01:01PM UTC coverage: 87.843% (-0.001%) from 87.844%
14907095122

Pull #14314

github

web-flow
Merge f338b045e into 25972ae86
Pull Request #14314: Add C API function to move a QkCircuit to Python

0 of 11 new or added lines in 1 file covered. (0.0%)

96 existing lines in 4 files now uncovered.

74605 of 84930 relevant lines covered (87.84%)

432644.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.96
/qiskit/primitives/containers/observables_array.py
1
# This code is part of Qiskit.
2
#
3
# (C) Copyright IBM 2024.
4
#
5
# This code is licensed under the Apache License, Version 2.0. You may
6
# obtain a copy of this license in the LICENSE.txt file in the root directory
7
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8
#
9
# Any modifications or derivative works of this code must retain this
10
# copyright notice, and modified files need to carry a notice indicating
11
# that they have been altered from the originals.
12

13

14
"""
15
ND-Array container class for Estimator observables.
16
"""
17
from __future__ import annotations
1✔
18

19
import re
1✔
20
from copy import deepcopy
1✔
21
from collections.abc import Iterable, Mapping as _Mapping
1✔
22
from functools import lru_cache
1✔
23
from typing import Union, Mapping, overload, TYPE_CHECKING
1✔
24

25
import numpy as np
1✔
26
from numpy.typing import ArrayLike
1✔
27

28
from qiskit.quantum_info import Pauli, PauliList, SparsePauliOp, SparseObservable
1✔
29

30
from .object_array import object_array
1✔
31
from .shape import ShapedMixin, shape_tuple
1✔
32

33

34
if TYPE_CHECKING:
35
    from qiskit.transpiler.layout import TranspileLayout
36

37

38
# Public API classes
39
__all__ = ["ObservableLike", "ObservablesArrayLike"]
1✔
40

41
ObservableLike = Union[
1✔
42
    str,
43
    Pauli,
44
    SparsePauliOp,
45
    SparseObservable,
46
    Mapping[Union[str, Pauli], float],
47
]
48
"""Types that can be natively used to construct a Hermitian Estimator observable."""
1✔
49

50

51
ObservablesArrayLike = Union[ObservableLike, ArrayLike]
1✔
52
"""Types that can be natively converted to an array of Hermitian Estimator observables."""
1✔
53

54

55
class ObservablesArray(ShapedMixin):
1✔
56
    """An ND-array of Hermitian observables for an :class:`.Estimator` primitive."""
57

58
    __slots__ = ("_array", "_shape")
1✔
59

60
    def __init__(
1✔
61
        self,
62
        observables: ObservablesArrayLike,
63
        num_qubits: int | None = None,
64
        copy: bool = True,
65
        validate: bool = True,
66
    ):
67
        """Initialize an observables array.
68

69
        Args:
70
            observables: An array-like of basis observable compatible objects.
71
            copy: Specify the ``copy`` kwarg of the :func:`.object_array` function
72
                when initializing observables.
73
            num_qubits: The number of qubits of the observables. If not specified, the number of
74
                qubits will be inferred from the observables. If specified, then the specified
75
                number of qubits must match the number of qubits in the observables.
76
            validate: If true, coerce entries into the internal format and validate them. If false,
77
                the input should already be an array-like.
78

79
        Raises:
80
            ValueError: If ``validate=True`` and the input observables array is not valid.
81
        """
82
        super().__init__()
1✔
83
        if isinstance(observables, ObservablesArray):
1✔
UNCOV
84
            observables = observables._array
×
85

86
        self._array = object_array(observables, copy=copy, list_types=(PauliList,))
1✔
87
        self._shape = self._array.shape
1✔
88
        self._num_qubits = num_qubits
1✔
89

90
        if validate:
1✔
91
            for ndi, obs in np.ndenumerate(self._array):
1✔
92
                basis_obs = self.coerce_observable(obs)
1✔
93
                if self._num_qubits is None:
1✔
94
                    self._num_qubits = basis_obs.num_qubits
1✔
95
                elif self._num_qubits != basis_obs.num_qubits:
1✔
96
                    raise ValueError(
1✔
97
                        "The number of qubits must be the same for all observables in the "
98
                        "observables array."
99
                    )
100
                self._array[ndi] = basis_obs
1✔
101
        elif self._num_qubits is None and self._array.size > 0:
1✔
102
            self._num_qubits = self._array.reshape(-1)[0].num_qubits
1✔
103

104
        # can happen for empty arrays
105
        if self._num_qubits is None:
1✔
UNCOV
106
            self._num_qubits = 0
×
107

108
    @staticmethod
1✔
109
    def _obs_to_dict(obs: SparseObservable) -> Mapping[str, float]:
1✔
110
        """Convert a sparse observable to a mapping from Pauli strings to coefficients"""
111
        result = {}
1✔
112
        for sparse_pauli_str, pauli_qubits, coeff in obs.to_sparse_list():
1✔
113

114
            if len(sparse_pauli_str) == 0:
1✔
115
                full_pauli_str = "I" * obs.num_qubits
1✔
116
            else:
117
                sorted_lists = sorted(zip(pauli_qubits, sparse_pauli_str))
1✔
118
                string_fragments = []
1✔
119
                prev_qubit = -1
1✔
120
                for qubit, pauli in sorted_lists:
1✔
121
                    string_fragments.append("I" * (qubit - prev_qubit - 1) + pauli)
1✔
122
                    prev_qubit = qubit
1✔
123

124
                string_fragments.append("I" * (obs.num_qubits - max(pauli_qubits) - 1))
1✔
125
                full_pauli_str = "".join(string_fragments)[::-1]
1✔
126

127
            # We know that the dictionary doesn't contain yet full_pauli_str as a key
128
            # because the observable is guaranteed to be simplified
129
            result[full_pauli_str] = np.real(coeff)
1✔
130

131
        return result
1✔
132

133
    def __repr__(self):
134
        prefix = f"{type(self).__name__}("
135
        suffix = f", shape={self.shape})"
136
        array = np.array2string(self.__array__(), prefix=prefix, suffix=suffix, threshold=50)
137
        return prefix + array + suffix
138

139
    def tolist(self) -> list | ObservableLike:
1✔
140
        """Convert to a nested list.
141

142
        Similar to Numpy's ``tolist`` method, the level of nesting
143
        depends on the dimension of the observables array. In the
144
        case of dimension 0 the method returns a single observable
145
        (``dict`` in the case of a weighted sum of Paulis) instead of a list.
146

147
        Examples::
148
            Return values for a one-element list vs one element:
149

150
                >>> from qiskit.primitives.containers.observables_array import ObservablesArray
151
                >>> oa = ObservablesArray.coerce(["Z"])
152
                >>> print(type(oa.tolist()))
153
                <class 'list'>
154
                >>> oa = ObservablesArray.coerce("Z")
155
                >>> print(type(oa.tolist()))
156
                <class 'dict'>
157
        """
158
        return self.__array__().tolist()
1✔
159

160
    def __array__(self, dtype=None, copy=None) -> np.ndarray:  # pylint: disable=unused-argument
1✔
161
        """Convert to a Numpy.ndarray"""
162
        if dtype is None or dtype == object:
1✔
163
            tmp_result = self.__getitem__(tuple(slice(None) for _ in self._array.shape))
1✔
164
            if len(self._array.shape) == 0:
1✔
165
                result = np.ndarray(shape=self._array.shape, dtype=dict)
1✔
166
                result[()] = tmp_result
1✔
167
            else:
168
                result = np.ndarray(tmp_result.shape, dtype=dict)
1✔
169
                for ndi, obs in np.ndenumerate(tmp_result._array):
1✔
170
                    result[ndi] = self._obs_to_dict(obs)
1✔
171
            return result
1✔
UNCOV
172
        raise ValueError("Type must be 'None' or 'object'")
×
173

174
    @overload
1✔
175
    def __getitem__(self, args: int | tuple[int, ...]) -> Mapping[str, float]: ...
1✔
176

177
    @overload
1✔
178
    def __getitem__(self, args: slice | tuple[slice, ...]) -> ObservablesArray: ...
1✔
179

180
    def __getitem__(self, args):
1✔
181
        item = self._array[args]
1✔
182
        if not isinstance(item, np.ndarray):
1✔
183
            return self._obs_to_dict(item)
1✔
184

185
        return ObservablesArray(item, copy=False, validate=False)
1✔
186

187
    def reshape(self, *shape: int | Iterable[int]) -> ObservablesArray:
1✔
188
        """Return a new array with a different shape.
189

190
        This results in a new view of the same arrays.
191

192
        Args:
193
            shape: The shape of the returned array.
194

195
        Returns:
196
            A new array.
197
        """
198
        shape = shape_tuple(*shape)
1✔
199
        return ObservablesArray(self._array.reshape(shape), copy=False, validate=False)
1✔
200

201
    def ravel(self) -> ObservablesArray:
1✔
202
        """Return a new array with one dimension.
203

204
        The returned array has a :attr:`shape` given by ``(size, )``, where
205
        the size is the :attr:`~size` of this array.
206

207
        Returns:
208
            A new flattened array.
209
        """
210
        return self.reshape(self.size)
1✔
211

212
    @property
1✔
213
    def num_qubits(self) -> int:
1✔
214
        """Return the observable array's number of qubits"""
215
        return self._num_qubits
1✔
216

217
    @classmethod
1✔
218
    def coerce_observable(cls, observable: ObservableLike) -> SparseObservable:
1✔
219
        """Format an observable-like object into the internal format.
220

221
        Args:
222
            observable: The observable-like to format.
223

224
        Returns:
225
            The coerced observable.
226

227
        Raises:
228
            TypeError: If the input cannot be formatted because its type is not valid.
229
            ValueError: If the input observable is invalid.
230
        """
231
        # Pauli-type conversions
232
        if isinstance(observable, SparsePauliOp):
1✔
233
            observable = SparseObservable.from_sparse_pauli_op(observable)
1✔
234
        elif isinstance(observable, Pauli):
1✔
235
            observable = SparseObservable.from_pauli(observable)
1✔
236
        elif isinstance(observable, str):
1✔
237
            observable = SparseObservable.from_label(observable)
1✔
238
        elif isinstance(observable, _Mapping):
1✔
239
            term_list = []
1✔
240
            for basis, coeff in observable.items():
1✔
241
                if isinstance(basis, str):
1✔
242
                    term_list.append((basis, coeff))
1✔
243
                elif isinstance(basis, Pauli):
1✔
244
                    unphased_basis, phase = basis[:].to_label(), basis.phase
1✔
245
                    term_list.append((unphased_basis, complex(0, 1) ** phase * coeff))
1✔
246
                else:
UNCOV
247
                    raise TypeError(f"Invalid observable basis type: {type(basis)}")
×
248
            observable = SparseObservable.from_list(term_list)
1✔
249

250
        if isinstance(observable, SparseObservable):
1✔
251
            # Check that the operator has real coeffs
252
            coeffs = np.real_if_close(observable.coeffs)
1✔
253
            if np.iscomplexobj(coeffs):
1✔
254
                raise ValueError(
1✔
255
                    "Non-Hermitian input observable: the input SparsePauliOp has non-zero"
256
                    " imaginary part in its coefficients."
257
                )
258

259
            return SparseObservable.from_raw_parts(
1✔
260
                observable.num_qubits,
261
                coeffs,
262
                observable.bit_terms,
263
                observable.indices,
264
                observable.boundaries,
265
            ).simplify(tol=0)
266

UNCOV
267
        raise TypeError(f"Invalid observable type: {type(observable)}")
×
268

269
    @classmethod
1✔
270
    def coerce(cls, observables: ObservablesArrayLike) -> ObservablesArray:
1✔
271
        """Coerce ObservablesArrayLike into ObservableArray.
272

273
        Args:
274
            observables: an object to be observables array.
275

276
        Returns:
277
            A coerced observables array.
278
        """
279
        if isinstance(observables, ObservablesArray):
1✔
280
            return observables
1✔
281
        return cls(observables)
1✔
282

283
    def equivalent(self, other: ObservablesArray, tol: float = 1e-08) -> bool:
1✔
284
        """Compute whether the observable arrays are equal within a given tolerance.
285

286
        Args:
287
            other: Another observables array to compare with.
288
            tol: The tolerance to provide to :attr:`~.SparseObservable.simplify` during checking.
289

290
        Returns:
291
            Whether the two observables arrays have the same shape and number of qubits,
292
            and if so, whether they are equal within tolerance.
293
        """
294
        if self.num_qubits != other.num_qubits or self.shape != other.shape:
1✔
295
            return False
1✔
296

297
        zero_obs = SparseObservable.zero(self.num_qubits)
1✔
298
        for obs1, obs2 in zip(self._array.ravel(), other._array.ravel()):
1✔
299
            if (obs1 - obs2).simplify(tol) != zero_obs:
1✔
300
                return False
1✔
301

302
        return True
1✔
303

304
    def copy(self):
1✔
305
        """Return a deep copy of the array."""
306
        return deepcopy(self)
1✔
307

308
    def apply_layout(
1✔
309
        self, layout: TranspileLayout | list[int] | None, num_qubits: int | None = None
310
    ) -> ObservablesArray:
311
        """Apply a transpiler layout to this :class:`~.ObservablesArray`.
312

313
        Args:
314
            layout: Either a :class:`~.TranspileLayout`, a list of integers or None.
315
                    If both layout and ``num_qubits`` are none, a deep copy of the array is
316
                    returned.
317
            num_qubits: The number of qubits to expand the array to. If not
318
                provided then if ``layout`` is a :class:`~.TranspileLayout` the
319
                number of the transpiler output circuit qubits will be used by
320
                default. If ``layout`` is a list of integers the permutation
321
                specified will be applied without any expansion. If layout is
322
                None, the array will be expanded to the given number of qubits.
323

324
        Returns:
325
            A new :class:`.ObservablesArray` with the provided layout applied.
326

327
        Raises:
328
            QiskitError: ...
329
        """
330
        if layout is None and num_qubits is None:
1✔
331
            return self.copy()
1✔
332

333
        new_arr = np.ndarray(self.shape, dtype=SparseObservable)
1✔
334
        for ndi, obs in np.ndenumerate(self._array):
1✔
335
            new_arr[ndi] = obs.apply_layout(layout, num_qubits)
1✔
336

337
        return ObservablesArray(new_arr, validate=False)
1✔
338

339
    def validate(self):
1✔
340
        """Validate the consistency in observables array."""
341
        for obs in self._array.reshape(-1):
1✔
342
            if obs.num_qubits != self.num_qubits:
1✔
343
                raise ValueError(
1✔
344
                    "An observable was detected, whose number of qubits"
345
                    " does not match the array's number of qubits"
346
                )
347

348

349
@lru_cache(1)
1✔
350
def _regex_match(allowed_chars: str) -> re.Pattern:
1✔
351
    """Return pattern for matching if a string contains only the allowed characters."""
UNCOV
352
    return re.compile(f"^[{re.escape(allowed_chars)}]*$")
×
353

354

355
@lru_cache(1)
1✔
356
def _regex_invalid(allowed_chars: str) -> re.Pattern:
1✔
357
    """Return pattern for selecting invalid strings"""
UNCOV
358
    return re.compile(f"[^{re.escape(allowed_chars)}]")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc