"""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 import trial_registry
from semi_cr.core.trial.machine import (
PULSE_AMPLITUDE,
NQCPMachine,
OPXWiring,
build_machine_from_wiring,
)
from semi_cr.core.trial.shapes import YizhiPulse, YizhiPulse2D
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,
readout_amplitude: float = PULSE_AMPLITUDE,
readout_frequency: float = 0.0,
axis_scale: float = 1.0,
):
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,
readout_amplitude=readout_amplitude,
readout_frequency=readout_frequency,
)
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]
self._axis_scale = axis_scale
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,
readout_amplitude: float = PULSE_AMPLITUDE,
readout_frequency: float = 0.0,
axis_scale: float = 1.0,
):
selected_gate = gate if gate is not None else wiring.sweep_gates[0]
super().__init__(
qmm,
wiring,
steps=steps,
half_width=half_width,
readout_length=readout_length,
readout_amplitude=readout_amplitude,
readout_frequency=readout_frequency,
axis_scale=axis_scale,
)
self._gate = selected_gate
@property
def axes(self) -> tuple[np.ndarray, ...]:
return (self._axis * self._axis_scale,)
[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)
voltage_seq.step_to_voltages({self._gate: 0.0}, duration=16)
voltage_seq.ramp_to_zero(reset_tracker=True)
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,
readout_amplitude: float = PULSE_AMPLITUDE,
readout_frequency: float = 0.0,
half_widths: tuple[float, float] | None = None,
axis_scales: tuple[float, float] = (1.0, 1.0),
):
selected_gates = gates if gates is not None else wiring.sweep_gates
amplitudes = half_widths if half_widths is not None else (half_width, half_width)
super().__init__(
qmm,
wiring,
steps=steps,
half_width=half_width,
readout_length=readout_length,
readout_amplitude=readout_amplitude,
readout_frequency=readout_frequency,
axis_scale=axis_scales[0],
)
self._shape_2d = YizhiPulse2D(amplitude=amplitudes, steps=steps)
self._axis_1, self._axis_2 = self._shape_2d.get_axes()
self._shape_values_1 = [float(v) for v in self._axis_1]
self._shape_values_2 = [float(v) for v in self._axis_2]
self._order_1 = np.argsort(self._axis_1)
self._order_2 = np.argsort(self._axis_2)
self._gates = selected_gates
self._axis_scales = axis_scales
@property
def axes(self) -> tuple[np.ndarray, ...]:
# Same trajectory on both gates; rows = outer (gate 2), cols = inner (gate 1).
return (
self._axis_2[self._order_2] * self._axis_scales[1],
self._axis_1[self._order_1] * self._axis_scales[0],
)
[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_2):
with qua.for_each_(amp1, self._shape_values_1):
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)
voltage_seq.step_to_voltages({gate_1: 0.0, gate_2: 0.0}, duration=16)
voltage_seq.ramp_to_zero(reset_tracker=True)
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_2, self._order_1)]
# Add the trial classes to the central registry so the GUI can discover them.
trial_registry.append(Sweep1D)
trial_registry.append(Sweep2D)