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

Qblox #1088

Draft
wants to merge 16 commits into
base: parse-q1asm
Choose a base branch
from
Draft

Qblox #1088

Show file tree
Hide file tree
Changes from all commits
Commits
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
1,023 changes: 500 additions & 523 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ qibo = "^0.2.8"
numpy = "^1.26.4"
scipy = "^1.13.0"
pydantic = "^2.6.4"
qblox-instruments = { version = "0.12.0", optional = true }
qblox-instruments = { version = "^0.14.2", optional = true }
lark = { version = "^1.1.9", optional = true }
qcodes = { version = "^0.37.0", optional = true }
qcodes_contrib_drivers = { version = "0.18.0", optional = true }
Expand Down Expand Up @@ -59,7 +59,7 @@ nbsphinx = "^0.9.1"
ipython = "^8.12.0"
sphinx-copybutton = "^0.5.1"
# extras
qblox-instruments = "0.12.0"
qblox-instruments = "^0.14.2"
qcodes = "^0.37.0"
qcodes_contrib_drivers = "0.18.0"
qibosoq = { version = "^0.1.2", python = "<3.12" }
Expand Down
6 changes: 3 additions & 3 deletions src/qibolab/_core/components/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,21 @@ class DcChannel(Channel):
class IqChannel(Channel):
"""Channel that can be used to send IQ pulses."""

mixer: Optional[str]
mixer: Optional[str] = None
"""Name of the IQ mixer component corresponding to this channel.

None, if the channel does not have a mixer, or it does not need
configuration.
"""
lo: Optional[str]
lo: Optional[str] = None
"""Name of the local oscillator component corresponding to this channel.

None, if the channel does not have an LO, or it is not configurable.
"""


class AcquisitionChannel(Channel):
twpa_pump: Optional[str]
twpa_pump: Optional[str] = None
"""Name of the TWPA pump component.

None, if there is no TWPA, or it is not configurable.
Expand Down
2 changes: 1 addition & 1 deletion src/qibolab/_core/instruments/qblox/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from . import ast_
__all__ = []
2 changes: 2 additions & 0 deletions src/qibolab/_core/instruments/qblox/ast_.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from ...serialize import Model

__all__ = []


class Register(Model):
number: int
Expand Down
175 changes: 175 additions & 0 deletions src/qibolab/_core/instruments/qblox/cluster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from collections import defaultdict
from functools import cached_property
from typing import Optional

import qblox_instruments as qblox
from qblox_instruments.qcodes_drivers.module import Module
from qcodes.instrument import find_or_create_instrument

from qibolab._core.components.configs import Config
from qibolab._core.execution_parameters import ExecutionParameters
from qibolab._core.identifier import ChannelId, Result
from qibolab._core.instruments.abstract import Controller
from qibolab._core.sequence import PulseSequence
from qibolab._core.serialize import Model
from qibolab._core.sweeper import ParallelSweepers

from .sequence import Sequence

__all__ = ["Cluster"]

SAMPLING_RATE = 1


SlotId = int
SequencerId = int


class PortAddress(Model):
slot: SlotId
ports: tuple[int, Optional[int]]
input: bool = False

@classmethod
def from_path(cls, path: str):
"""Load address from :attr:`qibolab.Channel.path`."""
els = path.split("/")
assert len(els) == 2
ports = els[1][1:].split("_")
assert 1 <= len(ports) <= 2
return cls(
slot=int(els[0]),
ports=(int(ports[0]), int(ports[1]) if len(ports) == 2 else None),
input=els[1][0] == "i",
)

@property
def local_address(self):
"""Physical address within the module.

It will generate a string in the format ``<direction><channel>`` or
``<direction><I-channel>_<Q-channel>``.
``<direction>`` is ``in`` for a connection between an input and the acquisition
path, ``out`` for a connection from the waveform generator to an output, or
``io`` to do both.
The channels must be integer channel indices.
Only one channel is present for a real mode operating sequencer; two channels
are used for complex mode.

.. note::

Description adapted from
https://docs.qblox.com/en/main/api_reference/cluster.html#qblox_instruments.Cluster.connect_sequencer
"""
direction = "in" if self.input else "out"
channels = (
str(self.ports[0])
if self.ports[1] is None
else f"{self.ports[0]}_{self.ports[1]}"
)
return f"{direction}{channels}"


class Cluster(Controller):
name: str
"""Device name.

As described in:
https://docs.qblox.com/en/main/getting_started/setup.html#connecting-to-multiple-instruments
"""
bounds: str = "qblox/bounds"
_cluster: Optional[qblox.Cluster] = None

@cached_property
def _modules(self) -> dict[SlotId, Module]:
assert self._cluster is not None
return {mod.slot_idx: mod for mod in self._cluster.modules if mod.present()}

@property
def sampling_rate(self) -> int:
return SAMPLING_RATE

def connect(self):
if self.is_connected:
return

self._cluster = find_or_create_instrument(
qblox.Cluster, recreate=True, name=self.name, identifier=self.address
)
self._cluster.reset()

@property
def is_connected(self) -> bool:
return self._cluster is not None

def disconnect(self):
assert self._cluster is not None

for module in self._modules.values():
module.stop_sequencer()
self._cluster.reset()
self._cluster = None

def play(
self,
configs: dict[str, Config],
sequences: list[PulseSequence],
options: ExecutionParameters,
sweepers: list[ParallelSweepers],
) -> dict[int, Result]:
results = {}
for ps in sequences:
sequences_ = _prepare(ps, sweepers, options)
sequencers = self._upload(sequences_)
results |= self._execute(sequencers)
return results

def _upload(
self, sequences: dict[ChannelId, Sequence]
) -> dict[SlotId, dict[ChannelId, SequencerId]]:
channels_by_module = _channels_by_module()
sequencers = defaultdict(dict)
for mod, chs in channels_by_module.items():
module = self._modules[mod]
assert len(module.sequencers) > len(chs)
# Map sequencers to specific outputs (but first disable all sequencer connections)
module.disconnect_outputs()
for idx, (ch, sequencer) in enumerate(zip(chs, module.sequencers)):
sequencers[mod][ch] = idx
sequencer.sequence(sequences[ch].model_dump())
# Configure the sequencers to synchronize
sequencer.sync_en(True)
sequencer.connect_sequencer(
PortAddress.from_path(self.channels[ch].path).local_address
)

return sequencers

def _execute(self, sequencers: dict[SlotId, dict[ChannelId, SequencerId]]) -> dict:
# TODO: implement
for mod, seqs in sequencers.items():
module = self._modules[mod]
for seq in seqs.values():
module.arm_sequencer(seq)
module.start_sequencer()

return {}


def _channels_by_module() -> dict[SlotId, list[ChannelId]]:
# TODO: implement
return {}


def _prepare(
sequence: PulseSequence,
sweepers: list[ParallelSweepers],
options: ExecutionParameters,
) -> dict[ChannelId, Sequence]:
sequences = {}
for channel in sequence.channels:
filtered = PulseSequence((ch, pulse) for ch, pulse in sequence if ch == channel)
seq = Sequence.from_pulses(filtered, sweepers, options)
sequences[channel] = seq

return sequences
2 changes: 2 additions & 0 deletions src/qibolab/_core/instruments/qblox/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from .ast_ import INSTRUCTIONS, Comment, Line, Program

__all__ = []

GRAMMAR_FILE = Path(__file__).parent / "q1asm.lark"
GRAMMAR = GRAMMAR_FILE.read_text()

Expand Down
97 changes: 97 additions & 0 deletions src/qibolab/_core/instruments/qblox/platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from collections import defaultdict
from typing import Optional, Union

from qibolab._core.components.channels import (
AcquisitionChannel,
Channel,
DcChannel,
IqChannel,
)

__all__ = ["map_ports"]


def _eltype(el: str):
return "qubits" if el[0] == "q" else "couplers"
Comment on lines +14 to +15
Copy link
Member

Choose a reason for hiding this comment

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

I know this is WIP and I am not sure exactly how it is used, but I would expect drivers to be qubit/coupler agnostic and only play with channels and configs, without caring exactly which qubit each channel controls, but maybe only the channel type.

Copy link
Member Author

@alecandido alecandido Nov 4, 2024

Choose a reason for hiding this comment

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

This is just a support function for writing "simpler" platforms, which I moved from the current sketch of the platform.

The idea is that using this function is fully optional. But the function itself is opinionated, favoring certain layouts (hopefully, the most frequent ones), making it simpler to describe the connections.
An example of its usage is here:
https://github.com/qiboteam/qibolab_platforms_qrc/blob/e4011ded804135d758310082616860bb959d94fd/iqm5q/platform.py#L16-L27
(you can see that this is attempting to mirror the connections that you physically have in the lab, to make the definition simpler when you have that picture - the function is supposed to do the rest - see conventions and caveats in its docstring)

To be fair, the reason why I introduced this is that we're passing qubits and couplers separately in the platform, and consequently defining them in two separate sequences.
However, I refactored many times to improve the representation and implementation, and by now there is a single relic for that separation, i.e.:

if kind == "qubits":
channels[el.acquisition] = channels[el.acquisition].model_copy(
update={"probe": el.probe}
)

All the rest is already performed in the very same way.
So, since it is just post-processing, I should be able to concatenate qubits and couplers, and just post-process qubits for the acquisition.

Copy link
Member

Choose a reason for hiding this comment

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

I have not looked at the full content of the file in detail so maybe it is indeed useful, however my main concern (and what I don't understand) is why this is under qblox. I would expect the instrument to have no knowledge about qubits and couplers at all, as the Platform never communicates these objects to the instrument.

If it is actually useful, it would make more sense to lift this outside the drivers and make it usable by any platform.

Copy link
Member Author

Choose a reason for hiding this comment

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

As said above, these qubits & couplers issue I should be able to solve, and I will (try to) do it soon.

If it is actually useful, it would make more sense to lift this outside the drivers and make it usable by any platform.

We can consider generalizing it, with some pluggable choices. But for the time being it is tailored to Qblox in a few ways (not infinitely many).

The most outstanding example:

if mod.startswith("qcm_rf"):
return "drive", IqChannel
if mod.startswith("qcm"):
return "flux", DcChannel
if mod.startswith("qrm_rf"):

true that we could provide some mappings from module names. But not even all instruments have modules... (e.g. not QICK)

Let's say that I'm using Qblox to experiment the approach. But I'm not aiming to make something more general than that, at first shot.
And keeping it as a fully optional part, even for Qblox, we should be free to just leave it there at some point, and replace with some more general implementation (if general-enough, by reimplementing the current interface making use of the new one, otherwise by keeping both, and deprecating the Qblox-specific one).



def _chtype(mod: str, input: bool) -> tuple[str, type[Channel]]:
if mod.startswith("qcm_rf"):
return "drive", IqChannel
if mod.startswith("qcm"):
return "flux", DcChannel
if mod.startswith("qrm_rf"):
if input:
return "acquisition", AcquisitionChannel
return "probe", IqChannel
raise ValueError


def _port_channels(mod: str, port: Union[int, str], slot: int) -> dict:
if isinstance(port, str) and port.startswith("io"):
return {
"probe": IqChannel(path=f"{slot}/o{port[2:]}"),
"acquisition": AcquisitionChannel(path=f"{slot}/o{port[2:]}"),
}
port = f"o{port}" if isinstance(port, int) else port
name, cls = _chtype(mod, port[0] == "i")
return {name: cls(path=f"{slot}/{port}")}


def _premap(cluster: dict):
d = {
"qubits": defaultdict(lambda: defaultdict(dict)),
"couplers": defaultdict(lambda: defaultdict(dict)),
}

for mod, props in cluster.items():
slot = props[0]
for port, els in props[1].items():
for el in els:
nel = el[1:]
d[_eltype(el)][nel] |= _port_channels(mod, port, slot)

return d


def map_ports(cluster: dict, qubits: dict, couplers: Optional[dict] = None) -> dict:
"""Extract channels from compact representation.

Conventions:
- each item is a module
- the first element of each value is the module's slot ID
- the second element is a map from ports to qubits
- ports
- they are `i{n}` or `o{n}` for the inputs and outputs respectively
- `io{n}` is also allowed, to signal that both are connected (cater for the specific
case of the QRM_RF where there are only one port of each type)
- if it's just an integer, it is intended to be an output (equivalent to `o{n}`)
- values
- list of element names
- they are `q{name}` or `c{name}` for qubits and couplers respectively
- multiple elements are allowed, for multiplexed ports

.. note::

integer qubit names are not allowed

.. todo::

Currently channel types are inferred from the module type, encoded in its name. At
least an override should be allowed (per module, per port, per qubit).
"""
if couplers is None:
couplers = {}

premap = _premap(cluster)

channels = {}
for kind, elements in [("qubits", qubits), ("couplers", couplers)]:
for name, el in elements.items():
for chname, ch in premap[kind][name].items():
channels[getattr(el, chname)] = ch
if kind == "qubits":
channels[el.acquisition] = channels[el.acquisition].model_copy(
update={"probe": el.probe}
)
return channels
46 changes: 46 additions & 0 deletions src/qibolab/_core/instruments/qblox/sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Annotated

from pydantic import AfterValidator, PlainSerializer, PlainValidator

from qibolab._core.execution_parameters import ExecutionParameters
from qibolab._core.sequence import PulseSequence
from qibolab._core.serialize import ArrayList, Model
from qibolab._core.sweeper import ParallelSweepers

from .ast_ import Program
from .parse import parse

__all__ = []


class Waveform(Model):
data: Annotated[ArrayList, AfterValidator(lambda a: a.astype(float))]
index: int


Weight = Waveform


class Acquisition(Model):
num_bins: int
index: int


class Sequence(Model):
waveforms: dict[str, Waveform]
weights: dict[str, Weight]
acquisitions: dict[str, Acquisition]
program: Annotated[
Program, PlainSerializer(lambda p: p.asm()), PlainValidator(parse)
]

@classmethod
def from_pulses(
cls,
sequence: PulseSequence,
sweepers: list[ParallelSweepers],
options: ExecutionParameters,
):
return cls(
waveforms={}, weights={}, acquisitions={}, program=Program(elements=[])
)
Loading