Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation for Keysight QCS #944

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7d70d33
Initial implementation
sorewachigauyo Oct 1, 2024
4bca03f
fix: Fix sweeper implementation
sorewachigauyo Oct 1, 2024
79ef94f
fix: Linter fixes
sorewachigauyo Oct 1, 2024
a5fc2fb
fix: sweeper and result format fixes
sorewachigauyo Oct 3, 2024
145db4e
perf: delegate virtual z to hardware
sorewachigauyo Oct 16, 2024
7f72d61
style: explicit nanosecond unit conversion
sorewachigauyo Oct 17, 2024
ecb78cd
fix: fix delay implementation for flux pulses
sorewachigauyo Oct 17, 2024
3330088
fix: fix flux pulses being ignored
sorewachigauyo Oct 17, 2024
a0110c1
refactor: define sampling rate as class variable
sorewachigauyo Oct 17, 2024
e1bcbf7
refactor: use defaultdict for acquisiton map
sorewachigauyo Oct 17, 2024
03e3498
feat: expose driver in instruments
sorewachigauyo Oct 17, 2024
6e49f85
perf: optimize nested sweepers to use hardware if possible
sorewachigauyo Oct 18, 2024
64e897a
refactor: sweeper management
sorewachigauyo Oct 18, 2024
9bad5fb
refactor: move sweeper and pulses to seperate modules
sorewachigauyo Oct 18, 2024
9b6005a
refactor: Move result handling to side module
sorewachigauyo Oct 18, 2024
69bc929
refactor: inline RFWaveform creation
sorewachigauyo Oct 18, 2024
fd29be6
perf: rely on hardware for state determination
sorewachigauyo Oct 18, 2024
c29d344
refactor: move result parsing to side module
sorewachigauyo Oct 18, 2024
78f695b
fix: fix waveform conversion
sorewachigauyo Oct 22, 2024
2511e0e
refactor: module level imports and exports
sorewachigauyo Oct 22, 2024
e92da05
docs: fix comments
sorewachigauyo Oct 22, 2024
11a7b72
refactor: define supported sweepers as top level constant
sorewachigauyo Oct 23, 2024
5140abd
refactor: remove sweeper swaps
sorewachigauyo Oct 23, 2024
38fc84d
fix: fix result parsing bug
sorewachigauyo Oct 23, 2024
c0b9564
fix: fix sweepers
sorewachigauyo Oct 23, 2024
3a48f83
refactor: clean up result management
sorewachigauyo Oct 24, 2024
50de1d0
refactor: remove unused
sorewachigauyo Nov 1, 2024
402d266
docs: field docstrings
sorewachigauyo Nov 1, 2024
8c279d1
refactor: inline qcs program initialization
sorewachigauyo Nov 1, 2024
3a29ffa
feat: add config for qcs classifier
sorewachigauyo Nov 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ generated-members = [
]
# TODO: restore analysis when the support will cover the entier Python range, i.e. it
# will include py3.12 as well
ignored-modules = ["qm", "qualang_tools", "qibosoq"]
ignored-modules = ["qm", "qualang_tools", "qibosoq", "keysight"]

[tool.pytest.ini_options]
testpaths = ['tests/']
Expand Down
7 changes: 7 additions & 0 deletions src/qibolab/_core/instruments/keysight/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from . import components, qcs
from .components import *
from .qcs import *

__all__ = []
__all__ += qcs.__all__
__all__ += components.__all__
5 changes: 5 additions & 0 deletions src/qibolab/_core/instruments/keysight/components/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import configs
from .configs import *

__all__ = []
__all__ += configs.__all__
16 changes: 16 additions & 0 deletions src/qibolab/_core/instruments/keysight/components/configs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Annotated, Literal, Optional

from pydantic import Field

from qibolab._core.components import AcquisitionConfig
from qibolab._core.serialize import NdArray

__all__ = ["QcsAcquisitionConfig"]


class QcsAcquisitionConfig(AcquisitionConfig):
"""Acquisition config for Keysight QCS."""

kind: Literal["qcs-acquisition"] = "qcs-acquisition"

state_iq_values: Annotated[Optional[NdArray], Field(repr=False)] = None
169 changes: 169 additions & 0 deletions src/qibolab/_core/instruments/keysight/pulse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Utils for pulse handling."""

from collections import defaultdict
from collections.abc import Iterable
from typing import Union

from keysight import qcs

from qibolab._core.pulses import Drag, Envelope, Gaussian, PulseId, Rectangular
from qibolab._core.pulses.pulse import PulseLike

NS_TO_S = 1e-9


def generate_qcs_envelope(shape: Envelope) -> qcs.Envelope:
"""Converts a Qibolab pulse envelope to a QCS Envelope object."""
if isinstance(shape, Rectangular):
return qcs.ConstantEnvelope()

elif isinstance(shape, (Gaussian, Drag)):
return qcs.GaussianEnvelope(shape.rel_sigma)
Comment on lines +20 to +21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the motivation to include Drag here? It seems that this always returns a Gaussian, completely ignoring the DRAG beta parameter. If DRAG is not available then doesn't it make more sense to just raise an error, like for all other shapes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the DRAG is implemented here at https://github.com/qiboteam/qibolab/pull/944/files#diff-e3c18ab9440f0e68561a15f5e9497097a405ef090fd2612f4f3a362c71c83502R126-R127
and I need the gaussian as the base envelope for the summation of the base and derivative envelope.
(https://quantumbenchmark.com/docs/qcs/api/channels.html#rj-drag)

I could implement the DRAG based on Qibolab's implementation, but I would not like to create a static envelope especially when duration sweepers can change the envelope size.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sorewachigauyo I understand your concern, and now that you explained I'm even fine with your solution.

Not to enforce the change request, but as a general note, we suspect that most of the APIs are actually implementing duration sweepers in the same inefficient way you would do using Qibolab's envelopes (i.e. evaluating on software all the required arrays and uploading them).
This is not happening for QM, but there is a dedicated feature linked to that, and we're now exposing it as a separate sweeper (where the envelope is not uploaded multiple times, but interpolated on the device).

duration_interpolated = (auto(), _PULSE)

If you suspect Keysight to be able to interpolate as well, I'd recommend you to verify it (as much as you can), and expose it through that sweeper, instead of the bare duration one (there will be a fallback at some level, but they may have different caveats, related to timing and approximation).


else:
# TODO: Rework this code to support other Qibolab pulse envelopes
# raw_envelope = shape.i(num_samples) + 1j * shape.q(num_samples)
# return qcs.ArbitraryEnvelope(
# times=np.linspace(0, 1, num_samples), amplitudes=raw_envelope
# )
raise Exception("Envelope not supported")


def process_acquisition_channel_pulses(
program: qcs.Program,
pulses: Iterable[PulseLike],
frequency: Union[float, qcs.Scalar],
virtual_channel: qcs.Channels,
probe_virtual_channel: qcs.Channels,
sweeper_pulse_map: defaultdict[PulseId, dict[str, qcs.Scalar]],
classifier: qcs.Classifier = None,
):
"""Processes Qibolab pulses on the acquisition channel into QCS hardware
instructions and adds it to the current program.

Arguments:
program (qcs.Program): Program object for the current sequence.
pulses (Iterable[PulseLike]): Array of pulse objects to be processed.
frequency (Union[float, qcs.Scalar]): Frequency of the channel.
virtual_channel (qcs.Channels): QCS virtual digitizer channel.
probe_virtual_channel (qcs.Channels): QCS virtual AWG channel connected to the digitzer.
sweeper_pulse_map (defaultdict[PulseId, dict[str, qcs.Scalar]]): Map of pulse ID to map of parameter
to be swept and corresponding QCS variable.
"""

for pulse in pulses:
sweep_param_map = sweeper_pulse_map.get(pulse.id, {})

if pulse.kind == "delay":
qcs_pulse = qcs.Delay(
sweep_param_map.get("duration", pulse.duration * NS_TO_S)
)
program.add_waveform(qcs_pulse, virtual_channel)
program.add_waveform(qcs_pulse, probe_virtual_channel)

elif pulse.kind == "acquisition":
duration = sweep_param_map.get("duration", pulse.duration * NS_TO_S)
program.add_acquisition(duration, virtual_channel)

elif pulse.kind == "readout":
sweep_param_map = sweeper_pulse_map.get(pulse.probe.id, {})
qcs_pulse = qcs.RFWaveform(
duration=sweep_param_map.get(
"duration", pulse.probe.duration * NS_TO_S
),
envelope=generate_qcs_envelope(pulse.probe.envelope),
amplitude=sweep_param_map.get("amplitude", pulse.probe.amplitude),
rf_frequency=frequency,
instantaneous_phase=sweep_param_map.get(
"relative_phase", pulse.probe.relative_phase
),
)
integration_filter = qcs.IntegrationFilter(qcs_pulse)
program.add_waveform(qcs_pulse, probe_virtual_channel)
program.add_acquisition(integration_filter, virtual_channel, classifier)


def process_iq_channel_pulses(
program: qcs.Program,
pulses: Iterable[PulseLike],
frequency: Union[float, qcs.Scalar],
virtual_channel: qcs.Channels,
sweeper_pulse_map: defaultdict[PulseId, dict[str, qcs.Scalar]],
):
"""Processes Qibolab pulses on the IQ channel into QCS hardware
instructions and adds it to the current program.

Arguments:
program (qcs.Program): Program object for the current sequence.
pulses (Iterable[PulseLike]): Array of pulse objects to be processed.
frequency (Union[float, qcs.Scalar]): Frequency of the channel.
virtual_channel (qcs.Channels): QCS virtual RF AWG channel.
sweeper_pulse_map (defaultdict[PulseId, dict[str, qcs.Scalar]]): Map of pulse ID to map of parameter
to be swept and corresponding QCS variable.
"""
qcs_pulses = []
for pulse in pulses:
sweep_param_map = sweeper_pulse_map.get(pulse.id, {})

if pulse.kind == "delay":
qcs_pulse = qcs.Delay(
sweep_param_map.get("duration", pulse.duration * NS_TO_S)
)
elif pulse.kind == "virtualz":
qcs_pulse = qcs.PhaseIncrement(
phase=sweep_param_map.get("relative_phase", pulse.phase)
)
elif pulse.kind == "pulse":
qcs_pulse = qcs.RFWaveform(
duration=sweep_param_map.get("duration", pulse.duration * NS_TO_S),
envelope=generate_qcs_envelope(pulse.envelope),
amplitude=sweep_param_map.get("amplitude", pulse.amplitude),
rf_frequency=frequency,
instantaneous_phase=sweep_param_map.get(
"relative_phase", pulse.relative_phase
),
)
if pulse.envelope.kind == "drag":
qcs_pulse = qcs_pulse.drag(coeff=pulse.envelope.beta)
else:
raise ValueError("Unrecognized pulse type", pulse.kind)

qcs_pulses.append(qcs_pulse)

program.add_waveform(qcs_pulses, virtual_channel)


def process_dc_channel_pulses(
program: qcs.Program,
pulses: Iterable[PulseLike],
virtual_channel: qcs.Channels,
sweeper_pulse_map: defaultdict[PulseId, dict[str, qcs.Scalar]],
):
"""Processes Qibolab pulses on the DC channel into QCS hardware
instructions and adds it to the current program.

Arguments:
program (qcs.Program): Program object for the current sequence.
pulses (Iterable[PulseLike]): Array of pulse objects to be processed.
virtual_channel (qcs.Channels): QCS virtual baseband AWG channel.
sweeper_pulse_map (defaultdict[PulseId, dict[str, qcs.Scalar]]): Map of pulse ID to map of parameter
to be swept and corresponding QCS variable.
"""
qcs_pulses = []
for pulse in pulses:
sweep_param_map = sweeper_pulse_map.get(pulse.id, {})
if pulse.kind == "delay":
qcs_pulse = qcs.Delay(
sweep_param_map.get("duration", pulse.duration * NS_TO_S)
)
elif pulse.kind == "pulse":
qcs_pulse = qcs.DCWaveform(
duration=sweep_param_map.get("duration", pulse.duration * NS_TO_S),
envelope=generate_qcs_envelope(pulse.envelope),
amplitude=sweep_param_map.get("amplitude", pulse.amplitude),
)
else:
raise ValueError("Unrecognized pulse type", pulse.kind)
qcs_pulses.append(qcs_pulse)

program.add_waveform(qcs_pulses, virtual_channel)
167 changes: 167 additions & 0 deletions src/qibolab/_core/instruments/keysight/qcs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Qibolab driver for Keysight QCS instrument set."""

from collections import defaultdict
from functools import reduce
from typing import ClassVar

import numpy as np
from keysight import qcs

from qibolab._core.components import AcquisitionChannel, Config, DcChannel, IqChannel
from qibolab._core.execution_parameters import AveragingMode, ExecutionParameters
from qibolab._core.identifier import ChannelId, Result
from qibolab._core.instruments.abstract import Controller
from qibolab._core.pulses import PulseId
from qibolab._core.sequence import InputOps, PulseSequence
from qibolab._core.sweeper import ParallelSweepers

from .pulse import (
process_acquisition_channel_pulses,
process_dc_channel_pulses,
process_iq_channel_pulses,
)
from .results import fetch_result, parse_result
from .sweep import process_sweepers

NS_TO_S = 1e-9

__all__ = ["KeysightQCS"]


class KeysightQCS(Controller):
"""Driver for interacting with QCS controller server."""

bounds: str = "qcs/bounds"

qcs_channel_map: qcs.ChannelMapper
"""Map of QCS virtual channels to QCS physical channels."""
virtual_channel_map: dict[ChannelId, qcs.Channels]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the example platform here(*): https://github.com/qiboteam/qibolab_platforms_nqch/blob/qibolab-0.2/iqm5q/platform.py, I have the impression that this dictionary can be eliminated by using qibolab's channel.path, which I believe is currently not used. I am not sure if types can be worked out though, because our channel.path should be a str. If qcs.ChannelMapper provides string keys, you may be able to retain the information using just qcs_channel_map and channel.path, otherwise it may be tricky.

In any case, I have no experience with these particular instruments, so if you find it easier to use it this way, it is fine with me.

(*) Unrelated to the above, it would be useful to also have an example (not necessarily real/working) platform here, but we can postpone this for later, when we have better testing or even better documentation for how to use each driver.

Copy link
Collaborator Author

@sorewachigauyo sorewachigauyo Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to have a direct mapping because other approaches would involve some type casting or eval to get the necessary parameters from the channel object and start to warp the model.

The qcs.ChannelMapper does not refer to channels by string keys but offers an array view to the virtual channel bundles.

"""Map of Qibolab channel IDs to QCS virtual channels."""
sampling_rate: ClassVar[float] = (
qcs.SAMPLE_RATES[qcs.InstrumentEnum.M5300AWG] * NS_TO_S
)

def connect(self):
self.backend = qcs.HclBackend(self.qcs_channel_map, hw_demod=True)
self.backend.is_system_ready()

def create_program(
self,
sequence: PulseSequence,
configs: dict[str, Config],
sweepers: list[ParallelSweepers],
num_shots: int,
) -> tuple[qcs.Program, list[tuple[int, int]]]:

# SWEEPER MANAGEMENT
probe_channel_ids = {
chan.probe
for chan in self.channels.values()
if isinstance(chan, AcquisitionChannel)
}

(
hardware_sweepers,
software_sweepers,
sweeper_channel_map,
sweeper_pulse_map,
) = process_sweepers(sweepers, probe_channel_ids)
# Here we are telling the program to run hardware sweepers first, then software sweepers
# It is essential that we match the original sweeper order to the modified sweeper order
# to reconcile the results at the end
sweep = lambda program, sweepers: program.sweep(*sweepers)
program = reduce(
sweep,
software_sweepers,
reduce(sweep, hardware_sweepers, qcs.Program()).n_shots(num_shots),
)

# WAVEFORM COMPILATION
# Iterate over channels and convert qubit pulses to QCS waveforms
for channel_id in sequence.channels:
channel = self.channels[channel_id]
virtual_channel = self.virtual_channel_map[channel_id]

if isinstance(channel, AcquisitionChannel):
probe_channel_id = channel.probe
classifier_reference = configs[channel_id].state_iq_values
process_acquisition_channel_pulses(
program=program,
pulses=sequence.channel(channel_id),
frequency=sweeper_channel_map.get(
probe_channel_id, configs[probe_channel_id].frequency
),
virtual_channel=virtual_channel,
probe_virtual_channel=self.virtual_channel_map[probe_channel_id],
sweeper_pulse_map=sweeper_pulse_map,
classifier=(
None
if classifier_reference is None
else qcs.MinimumDistanceClassifier(classifier_reference)
),
)

elif isinstance(channel, IqChannel):
process_iq_channel_pulses(
program=program,
pulses=sequence.channel(channel_id),
frequency=sweeper_channel_map.get(
channel_id, configs[channel_id].frequency
),
virtual_channel=virtual_channel,
sweeper_pulse_map=sweeper_pulse_map,
)

elif isinstance(channel, DcChannel):
process_dc_channel_pulses(
program=program,
pulses=sequence.channel(channel_id),
virtual_channel=virtual_channel,
sweeper_pulse_map=sweeper_pulse_map,
)

return program

def play(
self,
configs: dict[str, Config],
sequences: list[PulseSequence],
options: ExecutionParameters,
sweepers: list[ParallelSweepers],
) -> dict[int, Result]:

if options.relaxation_time is not None:
self.backend._init_time = int(options.relaxation_time)

ret: dict[PulseId, np.ndarray] = {}
for sequence in sequences:
results = self.backend.apply(
self.create_program(
sequence.align_to_delays(), configs, sweepers, options.nshots
)
).results
acquisition_map: defaultdict[qcs.Channels, list[InputOps]] = defaultdict(
list
)

for channel_id, input_op in sequence.acquisitions:
channel = self.virtual_channel_map[channel_id]
acquisition_map[channel].append(input_op)

averaging = options.averaging_mode is not AveragingMode.SINGLESHOT
for channel, input_ops in acquisition_map.items():
raw = fetch_result(
results=results,
channel=channel,
acquisition_type=options.acquisition_type,
averaging=averaging,
)

for result, input_op in zip(raw.values(), input_ops):

ret[input_op.id] = parse_result(result, options)

return ret

def disconnect(self):
pass
Loading