"""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)]