Source code for semi_cr.core.trial.trial
"""The Trial abstraction: one methodology for acquiring data from the OPX1000.
A :class:`Trial` wraps a particular *way* of getting data (e.g. a 1D or 2D gate
sweep): it builds the QUA program, knows which output streams that program
saves, and knows how to turn the raw stream buffers into a physically
meaningful NumPy array.
Consumers only use :meth:`Trial.stream`::
for frame in trial.stream(): # Jupyter, scripts, ...
plot(np.abs(frame))
or hand the generator to a background thread for GUI/"on-line" use. Closing
the generator (``.close()``, ``break``, or garbage collection) cancels the
QUA job and closes the quantum machine.
Currently only continuous (infinite-loop) acquisition is supported; a oneshot
mode will be revisited when a concrete use case exists, since oneshot QUA
programs are structured differently.
"""
import threading
import time
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Mapping
from typing import TYPE_CHECKING
import numpy as np
from qm import Program, QuantumMachinesManager
if TYPE_CHECKING:
from qm.type_hinting.config_types import FullQuaConfig
# TODO: Handle groups of gates
[docs]
class Trial(ABC):
"""One methodology for acquiring data (e.g. a 1D or 2D gate sweep).
Subclasses provide the QUA program, the machine configuration it runs on,
and pure post-processing from raw stream buffers to a NumPy array. The
base class provides the acquisition machinery (:meth:`fetch`,
:meth:`stream`).
By convention :meth:`post_process` returns complex I + jQ in volts;
consumers pick the component or magnitude they care about.
"""
#: Names of the output streams the QUA program saves. :meth:`fetch` pulls
#: exactly these; override in subclasses whose programs save different
#: buffers.
stream_names: tuple[str, ...] = ("i", "q")
#: Delay between successive fetch attempts in :meth:`stream`.
POLL_INTERVAL_S = 0.01
def __init__(self, qmm: QuantumMachinesManager):
self._qmm = qmm
# --- subclass responsibilities -------------------------------------------
[docs]
@abstractmethod
def build_program(self) -> Program:
"""Build the (continuous, infinite-loop) QUA program for this trial."""
[docs]
@abstractmethod
def machine_config(self) -> "FullQuaConfig":
"""The QUA machine configuration the program runs on."""
[docs]
@abstractmethod
def post_process(self, raw: Mapping[str, np.ndarray]) -> np.ndarray:
"""Turn raw stream buffers (keyed by :attr:`stream_names`) into a
physical-units array, conventionally complex I + jQ volts.
Must be a pure function so it can be tested with canned arrays.
"""
@property
@abstractmethod
def axes(self) -> tuple[np.ndarray, ...]:
"""Coordinate arrays, one per dimension of the post-processed result,
so consumers can plot without knowing sweep internals."""
# --- concrete machinery --------------------------------------------------
[docs]
def fetch(self, job) -> dict[str, np.ndarray] | None:
"""Pull one raw frame from the job, or ``None`` if not all streams
have produced data yet."""
raw = {name: job.result_handles.get(name).fetch_all() for name in self.stream_names}
if any(value is None for value in raw.values()):
return None
return raw
[docs]
def stream(self) -> Generator[np.ndarray, None, None]:
"""Run the trial and yield post-processed frames until closed.
Owns the quantum-machine/job lifecycle: opens the machine, executes
the program, and on generator close cancels the job and closes the
machine. Frames are polled; the same buffer may be yielded more than
once — deduplication is the consumer's concern.
"""
qm = self._qmm.open_qm(self.machine_config())
job = None
try:
job = qm.execute(self.build_program())
while True:
raw = self.fetch(job)
if raw is not None:
yield self.post_process(raw)
time.sleep(self.POLL_INTERVAL_S)
finally:
if job is not None:
job.cancel()
qm.close()
[docs]
class ThreadedTrialRunner:
"""Consume a trial's stream on a daemon thread for "on-line" (GUI) use.
Frames are delivered to ``on_frame`` from the background thread; the
callback is responsible for its own thread safety. ``stop()`` closes the
stream, which cancels the QUA job and closes the quantum machine.
Trial parameters changed? Stop this runner and start a new one with a
freshly built trial.
"""
def __init__(self, trial: Trial, on_frame: Callable[[np.ndarray], None]):
self._trial = trial
self._on_frame = on_frame
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
[docs]
def start(self) -> None:
if self.is_running:
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
[docs]
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
def _run(self) -> None:
# The for-loop drives Trial.stream(); breaking out closes the
# generator (via garbage collection at latest), but we close
# explicitly so hardware is released before stop() returns.
frames = self._trial.stream()
try:
for frame in frames:
if self._stop_event.is_set():
break
self._on_frame(frame)
finally:
frames.close()