Analysing Results with DataArrays

Note

DataArray conversion requires BinaryCount format results — the SDK default. If you pass a custom CompilerConfig, ensure it includes results_format=QuantumResultsFormat().binary_count(). Passing results in any other format (e.g. raw()) raises ValueError.

In BinaryCount format the server returns shot counts keyed by observed bitstring, e.g. {"c": {"00": 479, "11": 521}}. This page shows how to convert those counts into a labelled 2-D scipp DataArray, which makes it straightforward to:

  • Slice by bit value — inspect a specific bitstring or a range of values

  • Aggregate — sum counts across all runs, or across all bitstrings

  • Filter — keep only bitstrings matching a wildcard pattern (e.g. "1*" for all states where the most-significant bit is 1)

  • Change endianness — translate between OQC’s big-endian notation and Qiskit’s little-endian notation

Understanding the DataArray structure

results_to_counts_dataarray() converts a list of JobResult objects into a 2-D array:

  • Row axis (dim="result") — one row per execution run.

  • Column axis (dim="bit") — one column per unique bitstring observed across all supplied results. Bitstrings absent from a particular run are filled with 0.

Two coordinates label the column axis:

  • "bit" — the physical integer value of each bitstring (always big-endian, regardless of the endianness option). "011"3.

  • "bitstring" — the human-readable string label in whichever endianness convention was requested.

Measurement counts for a 2-qubit Bell state (|Φ+⟩), 2 runs:

           bit=0      bit=3
       ("00")     ("11")
result=0:   479         521     ← run 1
result=1:   463         537     ← run 2

This structure lets you use scipp’s slicing and reduction operations directly without writing any custom loops.

Quick reference

import scipp as sc
from oqc_qcaas_sdk import Endianness
from oqc_qcaas_sdk.dataarray_utils import bits

# --- convert ---
da = outputs.to_counts_dataarray("c")         # from JobOutputProxy
da = result.to_counts_dataarray("c")           # from a single JobResult

# --- slice a single bitstring ---
da["bit", bits("11")]                          # select column where bit value = 3

# --- slice a range (half-open interval on the bit coordinate) ---
da["bit", bits("00"):bits("10")]               # columns 0 and 1 i.e. "00" and "01"

# --- sum across runs ---
sc.sum(da, dim="result")                       # total counts per bitstring

# --- sum across bitstrings ---
sc.sum(da, dim="bit")                          # total shots per run

# --- wildcard filter via proxy ---
high = outputs.like("1*", creg="c")            # MSB = 1  (big-endian / OQC)
high = outputs.like("*1", creg="c",
                     endianness=Endianness.LITTLE)  # same states, Qiskit notation

The bits() helper

bits() converts a bitstring to a unit-less scipp scalar so it can be used as an index or slice bound.

>>> from oqc_qcaas_sdk.dataarray_utils import bits, Endianness
>>> import scipp as sc

>>> bits("00")               # "00" big-endian → 0
<scipp.Variable> ()      int64        <no unit>  0

>>> bits("11")               # "11" big-endian → 3
<scipp.Variable> ()      int64        <no unit>  3

>>> bits("01")               # "01" big-endian → 1
<scipp.Variable> ()      int64        <no unit>  1

OQC uses big-endian notation: the leftmost character is the most-significant bit. Qiskit uses little-endian (leftmost = least significant). The same physical quantum state |01⟩ (qubit 1 measured as 1, qubit 0 as 0) is written "01" in OQC notation and "10" in Qiskit. bits() accepts an endianness argument so both notations reach the same column:

>>> bool(bits("01", Endianness.BIG) == bits("10", Endianness.LITTLE))
True

The "bit" coordinate in every DataArray always stores the physical (big-endian) integer value, so bits()-based slicing is consistent regardless of which endianness you chose when building the DataArray.

Wildcard filtering with like()

like() filters a proxy to the bitstring columns matching a pattern. Use '*' to match either bit, '0' or '1' to require an exact value. The pattern length must equal the register width.

# Keep only bitstrings where both qubits are the same (correlated states)
correlated = outputs.like("0*0", creg="c")  # first and last bit both '0'... for 3-qubit

# Big-endian: MSB = '1'
excited = outputs.like("1*", creg="c")

# Equivalent in Qiskit little-endian convention
excited_le = outputs.like("*1", creg="c", endianness=Endianness.LITTLE)

filter_counts_dataarray() is the lower- level equivalent that operates directly on an existing DataArray.

Using DataArrays with CompositeJob

When execute() completes it returns a JobOutputProxy containing one JobResult per child job. Because to_counts_dataarray and like both operate on a proxy, building a combined DataArray from all children is a single call — each child job becomes one row:

outputs = await cjob.execute()
da = outputs.to_counts_dataarray("c")

The resulting DataArray has shape (n_children, n_bitstrings). You can then index or aggregate it using standard scipp operations:

  • da["result", 0] — counts from child job 0

  • da["result", 1] — counts from child job 1

  • sc.sum(da, dim="result") — totals across all children

The same is true via outputs after execution:

da = cjob.outputs.to_counts_dataarray("c")

Partial success and errors

Not every child job is guaranteed to produce a JobResult. If some children fail, their outputs are JobError objects. Both to_counts_dataarray and like skip errors and only include successful results — but they emit a UserWarning to make the omission explicit, so the DataArray will have fewer rows than children in that case.

If you want strict all-or-nothing behaviour — raise immediately rather than silently drop errors — call raise_for_error() first:

# Raises on the first child error; otherwise returns self for chaining
da = outputs.raise_for_error().to_counts_dataarray("c")

If all children failed and there are no results at all, to_counts_dataarray raises ValueError with a message indicating that the proxy may be all-errors or empty.

Working with Qiskit output

Qiskit returns bitstrings in little-endian order — the leftmost character represents qubit 0 (the least-significant bit). OQC uses big-endian — the leftmost character is the most-significant bit.

The same entangled state that OQC labels "01" (qubit 1 = 0, qubit 0 = 1) is labelled "10" by Qiskit. Pass endianness=Endianness.LITTLE to have the DataArray’s "bitstring" coordinate and all wildcard patterns work in Qiskit’s convention, while the underlying "bit" integer coordinate remains the same physical value:

>>> from oqc_qcaas_sdk.dataarray_utils import (
...     bits, Endianness, results_to_counts_dataarray
... )
>>> from oqc_qcaas_sdk.job_output import JobResult
>>> import uuid, scipp as sc

>>> # Simulated OQC result for a 2-qubit register
>>> result = JobResult(data={"c": {"00": 479, "01": 10, "10": 12, "11": 499}})
>>> da_be = results_to_counts_dataarray([result], creg="c")
>>> da_le = results_to_counts_dataarray([result], creg="c",
...                                      endianness=Endianness.LITTLE)

>>> # OQC big-endian labels
>>> list(da_be.coords["bitstring"].values)
['00', '01', '10', '11']

>>> # Qiskit little-endian labels (each bitstring reversed)
>>> list(da_le.coords["bitstring"].values)
['00', '10', '01', '11']

>>> # The physical "bit" coordinate is identical in both cases
>>> list(da_be.coords["bit"].values) == list(da_le.coords["bit"].values)
True

>>> # bits() accepts the same endianness argument — the same physical column
>>> # is selected regardless of which convention you work in:
>>> int(da_be["bit", bits("01", Endianness.BIG)].values[0])
10
>>> int(da_le["bit", bits("10", Endianness.LITTLE)].values[0])
10