Register Layouts & Mappable Registers

[1]:
import numpy as np

from pulser.register.register_layout import RegisterLayout
from pulser import Sequence, Pulse

One of the strengths of neutral-atom QPUs is their ability to arrange the atoms in arbitrary configurations. Experimentally, this is realized by creating a layout of optical traps where individual atoms can be placed to create the desired Register.

Given an arbitrary register, a neutral-atom QPU will generate an associated layout that will then have to be calibrated. Each new calibration takes some time, so it is often prefered to reuse an existing layout that has already been calibrated, whenever possible.

Therefore, it can be of interest to the QPU provider to specify which layouts are already calibrated in their QPU, such that the user can reuse them to specify their Register. In Pulser, these layouts are provided as instances of the RegisterLayout class.

Arbitrary Layouts

A RegisterLayout layout is defined by a set of trap coordinates. These coordinates are systematically ordered in the same way, making two layouts with the same set of trap coordinates identical.

Below, we create an arbitrary layout of 20 traps randomly placed in a 2D plane. Optionally, a layout may also have an associated slug to help identifying it.

[2]:
# Generating random coordinates
np.random.seed(301122)  # Keeps results consistent between runs
traps = np.random.randint(0, 30, size=(20, 2))
traps = traps - np.mean(traps, axis=0)

# Creating the layout
layout = RegisterLayout(traps, slug="random_20")

Given a RegisterLayout instance, the best way to inspect it is through RegisterLayout.draw(). Notice the default ordering of the atoms (ascending order in x, if tied then in y, if tied then in z):

[3]:
layout.draw()
../_images/tutorials_reg_layouts_7_0.png

Useful properties

To access the trap coordinates: - RegisterLayout.traps_dict gives a mapping between trap IDs and coordinates - RegisterLayout.coords provides the ordered list of trap coordinates

To identify a layout, one can use its repr() for a unique identifier or its str() for the slug (if specified).

[4]:
print("The layout slug:", layout)
print("The unique ID layout:", repr(layout))
The layout slug: random_20
The unique ID layout: RegisterLayout_0119932f3fc4d0cf580fff471f18baad23e982084be676487841b8a4be41c978

Register definition

More often than not, a RegisterLayout will be created by the hardware provider and given to the user. From there, the user must define the desired Register to initialize the Sequence. This can be done in multiple ways:

1. Defined by the trap IDs:

You can find the ID of each trap from its drawing or from the RegisterLayout.traps_dict. With those, you can define your register (optionally providing a list of qubit IDs):

[5]:
trap_ids = [4, 8, 19, 0]
reg1 = layout.define_register(*trap_ids, qubit_ids=["a", "b", "c", "d"])
reg1.draw()
../_images/tutorials_reg_layouts_16_0.png

Note that the information of the layout is stored internally in the Register:

[6]:
reg1.layout
[6]:
RegisterLayout_0119932f3fc4d0cf580fff471f18baad23e982084be676487841b8a4be41c978

2. Defined from the trap coordinates:

Alternatively, you can find the trap IDs from the trap coordinates using the RegisterLayout.get_traps_from_coordinates() method, which compares the provided coordinates with those on the layout with 6 decimal places of precision.

[7]:
some_coords = layout.coords[
    np.random.choice(np.arange(layout.number_of_traps), size=10, replace=False)
]
trap_ids = layout.get_traps_from_coordinates(*some_coords)
reg2 = layout.define_register(*trap_ids)
reg2.draw()
../_images/tutorials_reg_layouts_21_0.png

Special Layouts

[8]:
from pulser.register.special_layouts import (
    SquareLatticeLayout,
    TriangularLatticeLayout,
)

On top of the generic RegisterLayout class, there are special classes for common layouts that include convenience methods to more easily define a Register. These are subclasses of RegisterLayout, so all the methods specified above will still work.

SquareLatticeLayout

SquareLatticeLayout specifies a layout from an underlying square lattice.

[9]:
square_layout = SquareLatticeLayout(7, 4, spacing=5)
print(square_layout)
square_layout.draw()
SquareLatticeLayout(7x4, 5.0µm)
../_images/tutorials_reg_layouts_27_1.png

With SquareLatticeLayout.rectangular_register() and SquareLatticeLayout.square_register(), one can conveniently define a new Register:

[10]:
square_layout.rectangular_register(rows=3, columns=4, prefix="a").draw()
../_images/tutorials_reg_layouts_29_0.png
[11]:
square_layout.square_register(side=3).draw()
../_images/tutorials_reg_layouts_30_0.png

TriangularLatticeLayout

TriangularLatticeLayout specifies a layout from an underlying triangular lattice.

[12]:
tri_layout = TriangularLatticeLayout(n_traps=100, spacing=5)
print(tri_layout)
tri_layout.draw()
TriangularLatticeLayout(100, 5.0µm)
../_images/tutorials_reg_layouts_33_1.png

With TriangularLatticeLayout.hexagonal_register() or TriangularLatticeLayout.rectangular_register(), one can easily define a Register from a subset of existing traps.

[13]:
tri_layout.hexagonal_register(n_atoms=50).draw()
../_images/tutorials_reg_layouts_35_0.png
[14]:
tri_layout.rectangular_register(rows=3, atoms_per_row=7).draw()
../_images/tutorials_reg_layouts_36_0.png

Devices with pre-calibrated layouts

[15]:
from pulser.devices import Device
from pulser.channels import Rydberg, Raman

TestDevice = Device(
    name="TestDevice",
    dimensions=2,
    rydberg_level=70,
    max_atom_num=100,
    max_radial_distance=50,
    max_layout_filling=0.4,
    min_atom_distance=4,
    channel_objects=(Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5),),
    pre_calibrated_layouts=(
        SquareLatticeLayout(10, 10, 4),
        TriangularLatticeLayout(100, 5),
    ),
)

When receiving a Device instance, it may include the layouts that are already calibrated and available to be used. To access them, simply run:

[16]:
TestDevice.calibrated_register_layouts
[16]:
{'SquareLatticeLayout(10x10, 4.0µm)': RegisterLayout_e0a40bea81ea9f3ef63dfbb16ee1fa85138e50a9402b1fa04822994b055c0b4f,
 'TriangularLatticeLayout(100, 5.0µm)': RegisterLayout_46d398a85ffbed2af2918ecfc59fe0ab265ee3ffdea3d734774bee2fc5c90145}

You can then choose one of these layouts to define your Register and start creating a Sequence:

[17]:
layout = TestDevice.calibrated_register_layouts[
    "SquareLatticeLayout(10x10, 4.0µm)"
]
reg = layout.square_register(6)
seq = Sequence(reg, TestDevice)

In general, when a device comes with pre_calibrated_layouts, using them is encouraged. However, nothing prevents a Sequence to be created with a register coming from another layout, as long as that layout is compatible with the device. For example:

[18]:
another_layout = SquareLatticeLayout(5, 5, 5)
assert another_layout not in TestDevice.pre_calibrated_layouts
reg_ = another_layout.square_register(3)
seq = Sequence(reg_, TestDevice)

However, it is not possible to use a register created from an invalid layout, even if the register is valid:

[19]:
bad_layout = TriangularLatticeLayout(
    200, 10
)  # This layout is too large for TestDevice
good_reg = bad_layout.hexagonal_register(
    10
)  # On its own, this register is valid in TestDevice
try:
    seq = Sequence(good_reg, TestDevice)
except ValueError as e:
    print(e)
The 'register' is associated with an incompatible register layout.

Maximum Layout Filling Fraction

Through the Device.max_layout_filling, a device also specifies how much a layout can be filled. Although the default value is 0.5, some devices might have slightly higher or lower values.

In the case of our TestDevice, we specified the maximum layout filling fraction to be 0.4 . This means that we can use up to 40% of a RegisterLayout to form our register.

Let us see what would happen if we were to go over this value (e.g. by making a register of 49 atoms from a layout with 100 atoms):

[20]:
layout = TestDevice.calibrated_register_layouts[
    "SquareLatticeLayout(10x10, 4.0µm)"
]
too_big_reg = layout.square_register(7)
try:
    seq = Sequence(too_big_reg, TestDevice)
except ValueError as e:
    print(e)
Given the number of traps in the layout and the device's maximum layout filling fraction, the given register has too many qubits (49). On this device, this layout can hold at most 40 qubits.

Mappable Registers

Finally, layouts enable the creation of a MappableRegister — a register with the traps of each qubit still to be defined. This register can then be used to create a sort of parametrized Sequence, where deciding which traps will be mapped to which qubits only happens when Sequence.build() is called.

For example, below we define a mappable register with 10 qubits.

[21]:
map_register = layout.make_mappable_register(n_qubits=10)
map_register.qubit_ids
[21]:
('q0', 'q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q7', 'q8', 'q9')

We now use this register in our simple sequence:

[22]:
seq = Sequence(map_register, TestDevice)
assert seq.is_register_mappable()

seq.declare_channel("rydberg", "rydberg_global")
seq.add(
    Pulse.ConstantPulse(duration=100, amplitude=1, detuning=0, phase=0),
    "rydberg",
)
seq.draw()
../_images/tutorials_reg_layouts_54_0.png

To define the register, we can then call Sequence.build(), indicating in the qubits argument the map between qubit IDs and trap IDs. Note that not all the qubit IDs need to be associated to a trap ID, and that the qubit IDs have to be defined in their order of appearance in MappableRegister.qubit_ids (it is not possible to associate a trap ID to qubit ID “q4” if no trap ID was assigned to qubit ID “q3”).

In this way, we can build multiple sequences, with only the Register changing from one to the other:

[23]:
seq1 = seq.build(qubits={"q0": 16, "q1": 19, "q2": 34})
print("First register:", seq1.register.qubits)

seq2 = seq.build(qubits={"q0": 0, "q2": 15, "q1": 20, "q3": 50})
print("Second register:", seq2.register.qubits)
First register: {'q0': array([-12.,   8.]), 'q1': array([-12.,  20.]), 'q2': array([-4.,  0.])}
Second register: {'q0': array([-16., -16.]), 'q1': array([ -8., -16.]), 'q2': array([-12.,   4.]), 'q3': array([  4., -16.])}