Source code for semi_cr.core.trial.sweep

"""Gate-voltage sweep trials for the OPX1000.

:class:`Sweep1D` sweeps one gate; :class:`Sweep2D` sweeps two. Both run
continuously (infinite QUA loop) and stream I/Q readout frames; consume them
via :meth:`~semi_cr.core.trial.trial.Trial.stream`.

The voltage trajectory is a :class:`~semi_cr.core.trial.shapes.YizhiPulse`
(ramp up, jump to the negative complement, ramp back) so the net charge on
the sticky gate channels stays zero. Raw buffers therefore arrive in
*trajectory* order; post-processing reorders them so frames are monotonic in
voltage and line up with :attr:`axes`.
"""

from __future__ import annotations

import numpy as np
from qm import Program, QuantumMachinesManager, qua
from qm.qua import program
from qualang_tools.units import unit

from semi_cr.core.trial.machine import NQCPMachine, OPXWiring, build_machine_from_wiring
from semi_cr.core.trial.shapes import YizhiPulse
from semi_cr.core.trial.trial import Trial

DEFAULT_STEPS = 101
DEFAULT_HALF_WIDTH_V = 0.05
DEFAULT_READOUT_LENGTH_NS = 10_000


def _demod_to_complex(raw: dict[str, np.ndarray], readout_length: int) -> np.ndarray:
    """Raw integer I/Q buffers -> complex I + jQ in volts."""
    u = unit()
    i = u.demod2volts(raw["i"], readout_length, single_demod=False)
    q = u.demod2volts(raw["q"], readout_length, single_demod=False)
    return i + 1j * q


class _GateSweep(Trial):
    """Shared plumbing for 1D/2D gate sweeps: machine, shape, voltage axis."""

    def __init__(
        self,
        qmm: QuantumMachinesManager,
        wiring: OPXWiring,
        *,
        steps: int = DEFAULT_STEPS,
        half_width: float = DEFAULT_HALF_WIDTH_V,
        readout_length: int = DEFAULT_READOUT_LENGTH_NS,
    ):
        super().__init__(qmm)
        #: Readout pulse length and per-step dwell time in ns; also sets the
        #: demodulation window. A calibration knob, not a wiring property.
        self._readout_length = readout_length
        self._machine: NQCPMachine = build_machine_from_wiring(wiring, readout_length=readout_length)
        self._shape = YizhiPulse(amplitude=half_width, steps=steps).get_shape()
        # for_each_ wants a plain sequence, not an ndarray
        self._shape_values: list[float] = [float(v) for v in self._shape]
        # Trajectory order -> voltage order, applied to raw buffers in post_process.
        self._order = np.argsort(self._shape)
        self._axis = self._shape[self._order]

    def machine_config(self):
        return self._machine.generate_config()


[docs] class Sweep1D(_GateSweep): """Continuously sweep one gate voltage, streaming I/Q per step. Frames have shape ``(steps,)``, complex I + jQ volts, ordered to match the (monotonic) voltage axis in :attr:`axes`. """ def __init__( self, qmm: QuantumMachinesManager, wiring: OPXWiring, *, gate: str | None = None, steps: int = DEFAULT_STEPS, half_width: float = DEFAULT_HALF_WIDTH_V, readout_length: int = DEFAULT_READOUT_LENGTH_NS, ): super().__init__(qmm, wiring, steps=steps, half_width=half_width, readout_length=readout_length) self._gate = gate if gate is not None else wiring.sweep_gates[0] @property def axes(self) -> tuple[np.ndarray, ...]: return (self._axis,)
[docs] def build_program(self) -> Program: with program() as prog: voltage_seq = self._machine.gate_set.new_sequence(True, True, True) amp = qua.declare(qua.fixed, value=0) stream_i = qua.declare_output_stream() stream_q = qua.declare_output_stream() with qua.infinite_loop_(): with qua.for_each_(amp, self._shape_values): voltage_seq.step_to_voltages({self._gate: amp}, duration=self._readout_length) i, q = self._machine.readout_resonator.measure("readout") qua.save(i, stream_i) qua.save(q, stream_q) with qua.stream_processing(): stream_i.buffer(len(self._shape)).save("i") stream_q.buffer(len(self._shape)).save("q") return prog
[docs] def post_process(self, raw) -> np.ndarray: return _demod_to_complex(raw, self._readout_length)[..., self._order]
[docs] class Sweep2D(_GateSweep): """Continuously raster two gate voltages, streaming an I/Q frame per raster. Frames have shape ``(steps, steps)``, complex I + jQ volts. Row index follows ``axes[0]`` (the second sweep gate, outer loop), column index follows ``axes[1]`` (the first sweep gate, inner loop); both monotonic. """ def __init__( self, qmm: QuantumMachinesManager, wiring: OPXWiring, *, gates: tuple[str, str] | None = None, steps: int = DEFAULT_STEPS, half_width: float = DEFAULT_HALF_WIDTH_V, readout_length: int = DEFAULT_READOUT_LENGTH_NS, ): super().__init__(qmm, wiring, steps=steps, half_width=half_width, readout_length=readout_length) self._gates = gates if gates is not None else wiring.sweep_gates @property def axes(self) -> tuple[np.ndarray, ...]: # Same trajectory on both gates; rows = outer (gate 2), cols = inner (gate 1). return (self._axis, self._axis)
[docs] def build_program(self) -> Program: gate_1, gate_2 = self._gates with program() as prog: voltage_seq = self._machine.gate_set.new_sequence(True, True, True) amp1 = qua.declare(qua.fixed, value=0) amp2 = qua.declare(qua.fixed, value=0) stream_i = qua.declare_output_stream() stream_q = qua.declare_output_stream() with qua.infinite_loop_(): with qua.for_each_(amp2, self._shape_values): with qua.for_each_(amp1, self._shape_values): voltage_seq.step_to_voltages({gate_1: amp1, gate_2: amp2}, duration=self._readout_length) i, q = self._machine.readout_resonator.measure("readout") qua.save(i, stream_i) qua.save(q, stream_q) with qua.stream_processing(): stream_i.buffer(len(self._shape), len(self._shape)).save("i") stream_q.buffer(len(self._shape), len(self._shape)).save("q") return prog
[docs] def post_process(self, raw) -> np.ndarray: frame = _demod_to_complex(raw, self._readout_length) return frame[np.ix_(self._order, self._order)]