diff --git a/.coveragerc b/.coveragerc index 5fe460104..ca184056d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,6 +6,8 @@ exclude_lines = pragma: no cover # Don't test pass statements pass + # These lines can never be covered + if TYPE_CHECKING: omit = tests/* @@ -18,3 +20,4 @@ show_missing = True source = pulser-core/pulser/ pulser-simulation/pulser_simulation/ + pulser-pasqal/pulser_pasqal/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c028fa1a..00b9b3a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,8 +56,9 @@ jobs: if: github.event_name != 'push' runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.7', '3.9'] + python-version: ["3.7", "3.11"] steps: - name: Check out Pulser uses: actions/checkout@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8154e7129..7d340b068 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,14 +48,24 @@ jobs: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: twine upload dist/* + - name: Confirm deployment + timeout-minutes: 5 + shell: bash + run: | + until pip download pulser==$version + do + echo "Failed to download from PyPI, will wait for upload and retry." + sleep 30 + done check-release: needs: deploy runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - name: Check out Pulser uses: actions/checkout@v3 @@ -79,6 +89,7 @@ jobs: - name: Test the installation shell: bash run: | + version="$(head -1 VERSION.txt)" python -c "import pulser; assert pulser.__version__ == '$version'" grep -e pytest dev_requirements.txt | sed 's/ //g' | xargs pip install pytest diff --git a/.github/workflows/pulser-setup/action.yml b/.github/workflows/pulser-setup/action.yml index 51b90b144..acfc7dfcb 100644 --- a/.github/workflows/pulser-setup/action.yml +++ b/.github/workflows/pulser-setup/action.yml @@ -21,7 +21,7 @@ runs: shell: bash run: | python -m pip install --upgrade pip - pip install -e ./pulser-core -e ./pulser-simulation + make dev-install - name: Install extra packages from the dev requirements if: "${{ inputs.extra-packages != '' }}" shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 338561c05..4ca5c6036 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,9 +10,10 @@ jobs: full-tests: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - name: Check out Pulser uses: actions/checkout@v3 @@ -22,4 +23,4 @@ jobs: python-version: ${{ matrix.python-version }} extra-packages: pytest - name: Run the unit tests & generate coverage report - run: pytest --cov --cov-fail-under=100 \ No newline at end of file + run: pytest --cov --cov-fail-under=100 diff --git a/.gitignore b/.gitignore index 6238e047a..3225aac88 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,4 @@ build/ docs/build/ dist/ env* -pulser.egg-info/ -pulser_simulation.egg-info/ -pulser_core.egg-info/ +*.egg-info/ diff --git a/.mypy.ini b/.mypy.ini index 62caff8ae..55e678547 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -1,5 +1,9 @@ [mypy] -files = pulser-core/pulser, pulser-simulation/pulser_simulation, tests +files = + pulser-core/pulser, + pulser-simulation/pulser_simulation, + pulser-pasqal/pulser_pasqal, + tests python_version = 3.8 warn_return_any = True warn_redundant_casts = True diff --git a/.readthedocs.yml b/.readthedocs.yml index 61cab3a91..2f39de3cd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -21,3 +21,4 @@ python: - requirements: dev_requirements.txt - requirements: pulser-core/rtd_requirements.txt - requirements: pulser-simulation/rtd_requirements.txt + - requirements: pulser-pasqal/rtd_requirements.txt diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..fa2aad32a --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: dev-install +dev-install: dev-install-core dev-install-simulation dev-install-pasqal + +.PHONY: dev-install-core +dev-install-core: + pip install -e ./pulser-core + +.PHONY: dev-install-simulation +dev-install-simulation: + pip install -e ./pulser-simulation + +.PHONY: dev-install-pasqal +dev-install-pasqal: + pip install -e ./pulser-pasqal \ No newline at end of file diff --git a/README.md b/README.md index ef6607edf..00b8e5af1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ If you wish to **install the development version of Pulser from source** instead ```bash git checkout develop -pip install -e ./pulser-core -e ./pulser-simulation +make dev-install ``` Bear in mind that this installation will track the contents of your local diff --git a/VERSION.txt b/VERSION.txt index f38fc5393..a3df0a695 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.7.3 +0.8.0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 5271ea8f1..0aeae25f5 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,7 @@ sphinx-rtd-theme # documentation theme sphinx_autodoc_typehints nbsphinx nbsphinx-link +ipython != 8.7.0 # Avoids bug with code highlighting # Not on PyPI # pandoc diff --git a/docs/source/apidoc/cloud.rst b/docs/source/apidoc/cloud.rst new file mode 100644 index 000000000..af977455e --- /dev/null +++ b/docs/source/apidoc/cloud.rst @@ -0,0 +1,9 @@ +************************ +Pasqal Cloud connection +************************ + +PasqalCloud +---------------------- + +.. autoclass:: pulser_pasqal.PasqalCloud + :members: diff --git a/docs/source/apidoc/core.rst b/docs/source/apidoc/core.rst index 05ea56199..c0a3de88f 100644 --- a/docs/source/apidoc/core.rst +++ b/docs/source/apidoc/core.rst @@ -71,36 +71,54 @@ Devices Structure of a Device ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The :class:`Device` class sets the structure of every device instance. +The :class:`Device` class sets the structure of a physical device, +while :class:`VirtualDevice` is a more permissive device type which can +only be used in emulators, as it does not necessarily represent the +constraints of a physical device. -.. automodule:: pulser.devices._device_datacls - :members: +Illustrative instances of :class:`Device` (see `Physical Devices`_) and :class:`VirtualDevice` +(the `MockDevice`) come included in the `pulser.devices` module. + +.. autoclass:: pulser.devices._device_datacls.Device + :members: + +.. autoclass:: pulser.devices._device_datacls.VirtualDevice + :members: + +.. _Physical Devices: Physical Devices ^^^^^^^^^^^^^^^^^^^ -Each device instance holds the characteristics of a physical device, +Each `Device`` instance holds the characteristics of a physical device, which when associated with a :class:`pulser.Sequence` condition its development. .. autodata:: pulser.devices.Chadoq2 .. autodata:: pulser.devices.IroiseMVP -The MockDevice -^^^^^^^^^^^^^^^^ -A very permissive device that supports sequences which are currently unfeasible -on physical devices. Unlike with physical devices, its channels remain available -after declaration and can be declared again so as to have multiple channels -with the same characteristics. - -.. autodata:: pulser.devices.MockDevice Channels -^^^^^^^^^^^ -.. automodule:: pulser.channels +--------------------- + +Base Channel +^^^^^^^^^^^^^^^ +.. automodule:: pulser.channels.base_channel + :members: + + +Available Channels +^^^^^^^^^^^^^^^^^^^^^^^ +.. automodule:: pulser.channels.channels :members: :show-inheritance: +EOM Mode Configuration +^^^^^^^^^^^^^^^^^^^^^^^^ +.. automodule:: pulser.channels.eom + :members: + :show-inheritance: + Sampler ------------------ .. automodule:: pulser.sampler.sampler diff --git a/docs/source/apidoc/pulser.rst b/docs/source/apidoc/pulser.rst index 42ce1e873..4ac1d617f 100644 --- a/docs/source/apidoc/pulser.rst +++ b/docs/source/apidoc/pulser.rst @@ -6,3 +6,4 @@ API Reference core simulation + cloud diff --git a/docs/source/index.rst b/docs/source/index.rst index dee0fb016..908cae189 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -69,9 +69,11 @@ computers and simulators, check the pages in :doc:`review`. tutorials/phase_shifts_vz_gates tutorials/composite_wfs tutorials/paramseqs + tutorials/reg_layouts tutorials/interpolated_wfs tutorials/serialization tutorials/slm_mask + tutorials/output_mod_eom .. toctree:: :maxdepth: 1 diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 5ccc60f7c..36d348199 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -33,7 +33,7 @@ the ``develop`` branch - which holds the development (unstable) version of Pulse and install from source by running: :: git checkout develop - pip install -e ./pulser-core -e ./pulser-simulation + make dev-install Bear in mind that your installation will track the contents of your local Pulser repository folder, so if you checkout a different branch (e.g. ``master``), diff --git a/docs/source/tutorials/output_mod_eom.nblink b/docs/source/tutorials/output_mod_eom.nblink new file mode 100644 index 000000000..cace4a8e7 --- /dev/null +++ b/docs/source/tutorials/output_mod_eom.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../tutorials/advanced_features/Output Modulation and EOM Mode.ipynb" +} diff --git a/docs/source/tutorials/reg_layouts.nblink b/docs/source/tutorials/reg_layouts.nblink new file mode 100644 index 000000000..322268655 --- /dev/null +++ b/docs/source/tutorials/reg_layouts.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../tutorials/advanced_features/Register Layouts.ipynb" +} \ No newline at end of file diff --git a/packages.txt b/packages.txt index 888e92d31..c76983836 100644 --- a/packages.txt +++ b/packages.txt @@ -1,2 +1,3 @@ pulser-core -pulser-simulation \ No newline at end of file +pulser-simulation +pulser-pasqal \ No newline at end of file diff --git a/pulser-core/MANIFEST.in b/pulser-core/MANIFEST.in index 842ff617d..64e75d0ce 100644 --- a/pulser-core/MANIFEST.in +++ b/pulser-core/MANIFEST.in @@ -1,4 +1,5 @@ include README.md include LICENSE include pulser/devices/interaction_coefficients/C6_coeffs.json -include pulser/json/abstract_repr/schema.json +include pulser/json/abstract_repr/schemas/device-schema.json +include pulser/json/abstract_repr/schemas/sequence-schema.json diff --git a/pulser-core/pulser/channels.py b/pulser-core/pulser/channels.py deleted file mode 100644 index 782434fe3..000000000 --- a/pulser-core/pulser/channels.py +++ /dev/null @@ -1,320 +0,0 @@ -# Copyright 2020 Pulser Development Team -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""The various hardware channel types.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from typing import ClassVar, Optional, cast - -import numpy as np -from numpy.typing import ArrayLike -from scipy.fft import fft, fftfreq, ifft - -# Warnings of adjusted waveform duration appear just once -warnings.filterwarnings("once", "A duration of") - -# Conversion factor from modulation bandwith to rise time -# For more info, see https://tinyurl.com/bdeumc8k -MODBW_TO_TR = 0.48 - - -@dataclass(init=True, repr=False, frozen=True) -class Channel: - """Base class of a hardware channel. - - Not to be initialized itself, but rather through a child class and the - ``Local`` or ``Global`` classmethods. - - Args: - name: The name of channel. - basis: The addressed basis name. - addressing: "Local" or "Global". - max_abs_detuning: Maximum possible detuning (in rad/µs), in absolute - value. - max_amp: Maximum pulse amplitude (in rad/µs). - phase_jump_time: Time taken to change the phase between consecutive - pulses (in ns). - min_retarget_interval: Minimum time required between the ends of two - target instructions (in ns). - fixed_retarget_t: Time taken to change the target (in ns). - max_targets: How many qubits can be addressed at once by the same beam. - clock_period: The duration of a clock cycle (in ns). The duration of a - pulse or delay instruction is enforced to be a multiple of the - clock cycle. - min_duration: The shortest duration an instruction can take. - max_duration: The longest duration an instruction can take. - mod_bandwidth: The modulation bandwidth at -3dB (50% redution), in MHz. - - Example: - To create a channel targeting the 'ground-rydberg' transition globally, - call ``Rydberg.Global(...)``. - """ - - name: ClassVar[str] - basis: ClassVar[str] - addressing: str - max_abs_detuning: float - max_amp: float - phase_jump_time: int = 0 - min_retarget_interval: Optional[int] = None - fixed_retarget_t: Optional[int] = None - max_targets: Optional[int] = None - clock_period: int = 4 # ns - min_duration: int = 16 # ns - max_duration: int = 67108864 # ns - mod_bandwidth: Optional[float] = None # MHz - - @property - def rise_time(self) -> int: - """The rise time (in ns). - - Defined as the time taken to go from 10% to 90% output in response to - a step change in the input. - """ - if self.mod_bandwidth: - return int(MODBW_TO_TR / self.mod_bandwidth * 1e3) - else: - return 0 - - @classmethod - def Local( - cls, - max_abs_detuning: float, - max_amp: float, - phase_jump_time: int = 0, - min_retarget_interval: int = 220, - fixed_retarget_t: int = 0, - max_targets: int = 1, - **kwargs: int, - ) -> Channel: - """Initializes the channel with local addressing. - - Args: - max_abs_detuning: Maximum possible detuning (in rad/µs), in - absolute value. - max_amp: Maximum pulse amplitude (in rad/µs). - phase_jump_time: Time taken to change the phase between - consecutive pulses (in ns). - min_retarget_interval (int): Minimum time required between two - target instructions (in ns). - fixed_retarget_t: Time taken to change the target (in ns). - max_targets: Maximum number of atoms the channel can target - simultaneously. - """ - return cls( - "Local", - max_abs_detuning, - max_amp, - phase_jump_time, - min_retarget_interval, - fixed_retarget_t, - max_targets, - **kwargs, - ) - - @classmethod - def Global( - cls, - max_abs_detuning: float, - max_amp: float, - phase_jump_time: int = 0, - **kwargs: int, - ) -> Channel: - """Initializes the channel with global addressing. - - Args: - max_abs_detuning: Maximum possible detuning (in rad/µs), in - absolute value. - max_amp: Maximum pulse amplitude (in rad/µs). - phase_jump_time: Time taken to change the phase between - consecutive pulses (in ns). - """ - return cls( - "Global", max_abs_detuning, max_amp, phase_jump_time, **kwargs - ) - - def validate_duration(self, duration: int) -> int: - """Validates and adapts the duration of an instruction on this channel. - - Args: - duration: The duration to validate. - - Returns: - The duration, potentially adapted to the channels specs. - """ - try: - _duration = int(duration) - except (TypeError, ValueError): - raise TypeError( - "duration needs to be castable to an int but " - "type %s was provided" % type(duration) - ) - - if duration < self.min_duration: - raise ValueError( - "duration has to be at least " + f"{self.min_duration} ns." - ) - - if duration > self.max_duration: - raise ValueError( - "duration can be at most " + f"{self.max_duration} ns." - ) - - if duration % self.clock_period != 0: - _duration += self.clock_period - _duration % self.clock_period - warnings.warn( - f"A duration of {duration} ns is not a multiple of " - f"the channel's clock period ({self.clock_period} " - f"ns). It was rounded up to {_duration} ns.", - stacklevel=4, - ) - return _duration - - def modulate( - self, input_samples: np.ndarray, keep_ends: bool = False - ) -> np.ndarray: - """Modulates the input according to the channel's modulation bandwidth. - - Args: - input_samples: The samples to modulate. - keep_ends: Assume the end values of the samples were kept - constant (i.e. there is no ramp from zero on the ends). - - Returns: - The modulated output signal. - """ - if not self.mod_bandwidth: - warnings.warn( - f"No modulation bandwidth defined for channel '{self}'," - " 'Channel.modulate()' returns the 'input_samples' unchanged.", - stacklevel=2, - ) - return input_samples - - # The cutoff frequency (fc) and the modulation transfer function - # are defined in https://tinyurl.com/bdeumc8k - fc = self.mod_bandwidth * 1e-3 / np.sqrt(np.log(2)) - if keep_ends: - samples = np.pad(input_samples, 2 * self.rise_time, mode="edge") - else: - samples = np.pad(input_samples, self.rise_time) - freqs = fftfreq(samples.size) - modulation = np.exp(-(freqs**2) / fc**2) - mod_samples = ifft(fft(samples) * modulation).real - if keep_ends: - # Cut off the extra ends - return cast( - np.ndarray, mod_samples[self.rise_time : -self.rise_time] - ) - return cast(np.ndarray, mod_samples) - - def calc_modulation_buffer( - self, - input_samples: ArrayLike, - mod_samples: ArrayLike, - max_allowed_diff: float = 1e-2, - ) -> tuple[int, int]: - """Calculates the minimal buffers needed around a modulated waveform. - - Args: - input_samples: The input samples. - mod_samples: The modulated samples. Must be of size - ``len(input_samples) + 2 * self.rise_time``. - max_allowed_diff: The maximum allowed difference between - the input and modulated samples at the end points. - - Returns: - The minimum buffer times at the start and end of - the samples, in ns. - """ - if not self.mod_bandwidth: - raise TypeError( - f"The channel {self} doesn't have a modulation bandwidth." - ) - - tr = self.rise_time - samples = np.pad(input_samples, tr) - diffs = np.abs(samples - mod_samples) <= max_allowed_diff - try: - # Finds the last index in the start buffer that's below the max - # allowed diff. Considers that the waveform could start at the next - # indice (hence the -1, since we are subtracting from tr) - start = tr - np.argwhere(diffs[:tr])[-1][0] - 1 - except IndexError: - start = tr - try: - # Finds the first index in the end buffer that's below the max - # allowed diff. The index value found matches the minimum length - # for this end buffer. - end = np.argwhere(diffs[-tr:])[0][0] - except IndexError: - end = tr - - return start, end - - def __repr__(self) -> str: - config = ( - f".{self.addressing}(Max Absolute Detuning: " - f"{self.max_abs_detuning} rad/µs, Max Amplitude: {self.max_amp}" - f" rad/µs, Phase Jump Time: {self.phase_jump_time} ns" - ) - if self.addressing == "Local": - config += ( - f", Minimum retarget time: {self.min_retarget_interval} ns, " - f"Fixed retarget time: {self.fixed_retarget_t} ns" - ) - if cast(int, self.max_targets) > 1: - config += f", Max targets: {self.max_targets}" - config += f", Basis: '{self.basis}'" - if self.mod_bandwidth: - config += f", Modulation Bandwidth: {self.mod_bandwidth} MHz" - return self.name + config + ")" - - -@dataclass(init=True, repr=False, frozen=True) -class Raman(Channel): - """Raman beam channel. - - Channel targeting the transition between the hyperfine ground states, in - which the 'digital' basis is encoded. See base class. - """ - - name: ClassVar[str] = "Raman" - basis: ClassVar[str] = "digital" - - -@dataclass(init=True, repr=False, frozen=True) -class Rydberg(Channel): - """Rydberg beam channel. - - Channel targeting the transition between the ground and rydberg states, - thus enconding the 'ground-rydberg' basis. See base class. - """ - - name: ClassVar[str] = "Rydberg" - basis: ClassVar[str] = "ground-rydberg" - - -@dataclass(init=True, repr=False, frozen=True) -class Microwave(Channel): - """Microwave adressing channel. - - Channel targeting the transition between two rydberg states, thus encoding - the 'XY' basis. See base class. - """ - - name: ClassVar[str] = "Microwave" - basis: ClassVar[str] = "XY" diff --git a/pulser-core/pulser/channels/__init__.py b/pulser-core/pulser/channels/__init__.py new file mode 100644 index 000000000..9645bddbd --- /dev/null +++ b/pulser-core/pulser/channels/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The various hardware channel types.""" + +from pulser.channels.channels import Microwave, Raman, Rydberg diff --git a/pulser-core/pulser/channels/base_channel.py b/pulser-core/pulser/channels/base_channel.py new file mode 100644 index 000000000..d949e566c --- /dev/null +++ b/pulser-core/pulser/channels/base_channel.py @@ -0,0 +1,499 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines the Channel ABC.""" + +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod +from dataclasses import dataclass, field, fields +from sys import version_info +from typing import Any, Optional, cast + +import numpy as np +from numpy.typing import ArrayLike +from scipy.fft import fft, fftfreq, ifft + +from pulser.channels.eom import MODBW_TO_TR, BaseEOM +from pulser.json.utils import obj_to_dict +from pulser.pulse import Pulse + +if version_info[:2] >= (3, 8): # pragma: no cover + from typing import Literal, get_args +else: # pragma: no cover + try: + from typing_extensions import Literal, get_args # type: ignore + except ImportError: + raise ImportError( + "Using pulser with Python version 3.7 requires the" + " `typing_extensions` module. Install it by running" + " `pip install typing-extensions`." + ) + +# Warnings of adjusted waveform duration appear just once +warnings.filterwarnings("once", "A duration of") + +CH_TYPE = Literal["Rydberg", "Raman", "Microwave"] +BASIS = Literal["ground-rydberg", "digital", "XY"] + + +@dataclass(init=True, repr=False, frozen=True) # type: ignore[misc] +class Channel(ABC): + """Base class of a hardware channel. + + Not to be initialized itself, but rather through a child class and the + ``Local`` or ``Global`` classmethods. + + Args: + addressing: "Local" or "Global". + max_abs_detuning: Maximum possible detuning (in rad/µs), in absolute + value. + max_amp: Maximum pulse amplitude (in rad/µs). + min_retarget_interval: Minimum time required between the ends of two + target instructions (in ns). + fixed_retarget_t: Time taken to change the target (in ns). + max_targets: How many qubits can be addressed at once by the same beam. + clock_period: The duration of a clock cycle (in ns). The duration of a + pulse or delay instruction is enforced to be a multiple of the + clock cycle. + min_duration: The shortest duration an instruction can take. + max_duration: The longest duration an instruction can take. + mod_bandwidth: The modulation bandwidth at -3dB (50% reduction), in + MHz. + + Example: + To create a channel targeting the 'ground-rydberg' transition globally, + call ``Rydberg.Global(...)``. + """ + + addressing: Literal["Global", "Local"] + max_abs_detuning: Optional[float] + max_amp: Optional[float] + min_retarget_interval: Optional[int] = None + fixed_retarget_t: Optional[int] = None + max_targets: Optional[int] = None + clock_period: int = 1 # ns + min_duration: int = 1 # ns + max_duration: Optional[int] = int(1e8) # ns + mod_bandwidth: Optional[float] = None # MHz + eom_config: Optional[BaseEOM] = field(init=False, default=None) + + @property + def name(self) -> CH_TYPE: + """The name of the channel.""" + _name = type(self).__name__ + options = get_args(CH_TYPE) + assert ( + _name in options + ), f"The channel must be one of {options}, not {_name}." + return cast(CH_TYPE, _name) + + @property + @abstractmethod + def basis(self) -> BASIS: + """The addressed basis name.""" + pass + + def __post_init__(self) -> None: + """Validates the channel's parameters.""" + internal_param_value_pairs = [ + ("name", CH_TYPE), + ("basis", BASIS), + ("addressing", Literal["Global", "Local"]), + ] + for param, type_options in internal_param_value_pairs: + value = getattr(self, param) + options = get_args(type_options) + assert ( + value in options + ), f"The channel {param} must be one of {options}, not {value}." + + parameters = [ + "max_amp", + "max_abs_detuning", + "clock_period", + "min_duration", + "max_duration", + "mod_bandwidth", + ] + non_negative = [ + "max_abs_detuning", + "min_retarget_interval", + "fixed_retarget_t", + ] + local_only = [ + "min_retarget_interval", + "fixed_retarget_t", + "max_targets", + ] + optional = [ + "max_amp", + "max_abs_detuning", + "max_duration", + "mod_bandwidth", + "max_targets", + ] + + if self.addressing == "Global": + for p in local_only: + assert ( + getattr(self, p) is None + ), f"'{p}' must be left as None in a Global channel." + else: + parameters += local_only + + for param in parameters: + value = getattr(self, param) + if param in optional: + prelude = "When defined, " + valid = value is None + elif value is None: + raise TypeError( + f"'{param}' can't be None in a '{self.addressing}' " + "channel." + ) + else: + prelude = "" + valid = False + if param in non_negative: + comp = "greater than or equal to zero" + valid = valid or value >= 0 + else: + comp = "greater than zero" + valid = valid or value > 0 + msg = prelude + f"'{param}' must be {comp}, not {value}." + if not valid: + raise ValueError(msg) + + if ( + self.max_duration is not None + and self.max_duration < self.min_duration + ): + raise ValueError( + f"When defined, 'max_duration'({self.max_duration}) must be" + " greater than or equal to 'min_duration'" + f"({self.min_duration})." + ) + + @property + def rise_time(self) -> int: + """The rise time (in ns). + + Defined as the time taken to go from 10% to 90% output in response to + a step change in the input. + """ + if self.mod_bandwidth: + return int(MODBW_TO_TR / self.mod_bandwidth * 1e3) + else: + return 0 + + @property + def phase_jump_time(self) -> int: + """Time taken to change the phase between consecutive pulses (in ns). + + Corresponds to two times the rise time. + """ + return self.rise_time * 2 + + def is_virtual(self) -> bool: + """Whether the channel is virtual (i.e. partially defined).""" + return bool(self._undefined_fields()) + + def supports_eom(self) -> bool: + """Whether the channel supports EOM mode operation.""" + return hasattr(self, "eom_config") and self.eom_config is not None + + def _undefined_fields(self) -> list[str]: + optional = [ + "max_amp", + "max_abs_detuning", + "max_duration", + ] + if self.addressing == "Local": + optional.append("max_targets") + return [field for field in optional if getattr(self, field) is None] + + @classmethod + def Local( + cls, + max_abs_detuning: Optional[float], + max_amp: Optional[float], + min_retarget_interval: int = 0, + fixed_retarget_t: int = 0, + max_targets: Optional[int] = None, + **kwargs: Any, + ) -> Channel: + """Initializes the channel with local addressing. + + Args: + max_abs_detuning: Maximum possible detuning (in rad/µs), in + absolute value. + max_amp: Maximum pulse amplitude (in rad/µs). + min_retarget_interval: Minimum time required between two + target instructions (in ns). + fixed_retarget_t: Time taken to change the target (in ns). + max_targets: Maximum number of atoms the channel can target + simultaneously. + + Keyword Args: + clock_period(int, default=4): The duration of a clock cycle + (in ns). The duration of a pulse or delay instruction is + enforced to be a multiple of the clock cycle. + min_duration(int, default=1): The shortest duration an + instruction can take. + max_duration(Optional[int], default=10000000): The longest + duration an instruction can take. + mod_bandwidth(Optional[float], default=None): The modulation + bandwidth at -3dB (50% reduction), in MHz. + """ + return cls( + "Local", + max_abs_detuning, + max_amp, + min_retarget_interval, + fixed_retarget_t, + max_targets, + **kwargs, + ) + + @classmethod + def Global( + cls, + max_abs_detuning: Optional[float], + max_amp: Optional[float], + **kwargs: Any, + ) -> Channel: + """Initializes the channel with global addressing. + + Args: + max_abs_detuning: Maximum possible detuning (in rad/µs), in + absolute value. + max_amp: Maximum pulse amplitude (in rad/µs). + + Keyword Args: + clock_period(int, default=4): The duration of a clock cycle + (in ns). The duration of a pulse or delay instruction is + enforced to be a multiple of the clock cycle. + min_duration(int, default=1): The shortest duration an + instruction can take. + max_duration(Optional[int], default=10000000): The longest + duration an instruction can take. + mod_bandwidth(Optional[float], default=None): The modulation + bandwidth at -3dB (50% reduction), in MHz. + """ + return cls("Global", max_abs_detuning, max_amp, **kwargs) + + def validate_duration(self, duration: int) -> int: + """Validates and adapts the duration of an instruction on this channel. + + Args: + duration: The duration to validate. + + Returns: + The duration, potentially adapted to the channels specs. + """ + try: + _duration = int(duration) + except (TypeError, ValueError): + raise TypeError( + "duration needs to be castable to an int but " + "type %s was provided" % type(duration) + ) + + if duration < self.min_duration: + raise ValueError( + "duration has to be at least " + f"{self.min_duration} ns." + ) + + if self.max_duration is not None and duration > self.max_duration: + raise ValueError( + "duration can be at most " + f"{self.max_duration} ns." + ) + + if duration % self.clock_period != 0: + _duration += self.clock_period - _duration % self.clock_period + warnings.warn( + f"A duration of {duration} ns is not a multiple of " + f"the channel's clock period ({self.clock_period} " + f"ns). It was rounded up to {_duration} ns.", + stacklevel=4, + ) + return _duration + + def validate_pulse(self, pulse: Pulse) -> None: + """Checks if a pulse can be executed this channel. + + Args: + pulse: The pulse to validate. + channel_id: The channel ID used to index the chosen channel + on this device. + """ + if not isinstance(pulse, Pulse): + raise TypeError( + f"'pulse' must be of type Pulse, not of type {type(pulse)}." + ) + + if self.max_amp is not None and np.any( + pulse.amplitude.samples > self.max_amp + ): + raise ValueError( + "The pulse's amplitude goes over the maximum " + "value allowed for the chosen channel." + ) + if self.max_abs_detuning is not None and np.any( + np.round(np.abs(pulse.detuning.samples), decimals=6) + > self.max_abs_detuning + ): + raise ValueError( + "The pulse's detuning values go out of the range " + "allowed for the chosen channel." + ) + + def modulate( + self, + input_samples: np.ndarray, + keep_ends: bool = False, + eom: bool = False, + ) -> np.ndarray: + """Modulates the input according to the channel's modulation bandwidth. + + Args: + input_samples: The samples to modulate. + keep_ends: Assume the end values of the samples were kept + constant (i.e. there is no ramp from zero on the ends). + eom: Whether to calculate the modulation using the EOM + bandwidth. + + Returns: + The modulated output signal. + """ + if eom: + if not self.supports_eom(): + raise TypeError(f"The channel {self} does not have an EOM.") + eom_config = cast(BaseEOM, self.eom_config) + mod_bandwidth = eom_config.mod_bandwidth + rise_time = eom_config.rise_time + + elif not self.mod_bandwidth: + warnings.warn( + f"No modulation bandwidth defined for channel '{self}'," + " 'Channel.modulate()' returns the 'input_samples' unchanged.", + stacklevel=2, + ) + return input_samples + else: + mod_bandwidth = self.mod_bandwidth + rise_time = self.rise_time + + # The cutoff frequency (fc) and the modulation transfer function + # are defined in https://tinyurl.com/bdeumc8k + fc = mod_bandwidth * 1e-3 / np.sqrt(np.log(2)) + if keep_ends: + samples = np.pad(input_samples, 2 * rise_time, mode="edge") + else: + samples = np.pad(input_samples, rise_time) + freqs = fftfreq(samples.size) + modulation = np.exp(-(freqs**2) / fc**2) + mod_samples = ifft(fft(samples) * modulation).real + if keep_ends: + # Cut off the extra ends + return cast(np.ndarray, mod_samples[rise_time:-rise_time]) + return cast(np.ndarray, mod_samples) + + def calc_modulation_buffer( + self, + input_samples: ArrayLike, + mod_samples: ArrayLike, + max_allowed_diff: float = 1e-2, + eom: bool = False, + ) -> tuple[int, int]: + """Calculates the minimal buffers needed around a modulated waveform. + + Args: + input_samples: The input samples. + mod_samples: The modulated samples. Must be of size + ``len(input_samples) + 2 * self.rise_time``. + max_allowed_diff: The maximum allowed difference between + the input and modulated samples at the end points. + eom: Whether to calculate the modulation buffers with the EOM + bandwidth. + + Returns: + The minimum buffer times at the start and end of + the samples, in ns. + """ + if eom: + if not self.supports_eom(): + raise TypeError(f"The channel {self} does not have an EOM.") + tr = cast(BaseEOM, self.eom_config).rise_time + else: + if not self.mod_bandwidth: + raise TypeError( + f"The channel {self} doesn't have a modulation bandwidth." + ) + tr = self.rise_time + samples = np.pad(input_samples, tr) + diffs = np.abs(samples - mod_samples) <= max_allowed_diff + try: + # Finds the last index in the start buffer that's below the max + # allowed diff. Considers that the waveform could start at the next + # indice (hence the -1, since we are subtracting from tr) + start = tr - np.argwhere(diffs[:tr])[-1][0] - 1 + except IndexError: + start = tr + try: + # Finds the first index in the end buffer that's below the max + # allowed diff. The index value found matches the minimum length + # for this end buffer. + end = np.argwhere(diffs[-tr:])[0][0] + except IndexError: + end = tr + + return start, end + + def __repr__(self) -> str: + config = ( + f".{self.addressing}(Max Absolute Detuning: " + f"{self.max_abs_detuning}" + f"{' rad/µs' if self.max_abs_detuning else ''}, " + f"Max Amplitude: {self.max_amp}" + f"{' rad/µs' if self.max_amp else ''}" + ) + if self.addressing == "Local": + config += ( + f", Minimum retarget time: {self.min_retarget_interval} ns, " + f"Fixed retarget time: {self.fixed_retarget_t} ns" + ) + if self.max_targets is not None: + config += f", Max targets: {self.max_targets}" + config += ( + f", Clock period: {self.clock_period} ns" + f", Minimum pulse duration: {self.min_duration} ns" + ) + if self.max_duration is not None: + config += f", Maximum pulse duration: {self.max_duration} ns" + if self.mod_bandwidth: + config += f", Modulation Bandwidth: {self.mod_bandwidth} MHz" + config += f", Basis: '{self.basis}')" + return self.name + config + + def _to_dict(self) -> dict[str, Any]: + params = { + f.name: getattr(self, f.name) for f in fields(self) if f.init + } + return obj_to_dict(self, _module="pulser.channels", **params) + + def _to_abstract_repr(self, id: str) -> dict[str, Any]: + params = {f.name: getattr(self, f.name) for f in fields(self)} + return {"id": id, "basis": self.basis, **params} diff --git a/pulser-core/pulser/channels/channels.py b/pulser-core/pulser/channels/channels.py new file mode 100644 index 000000000..0373b3879 --- /dev/null +++ b/pulser-core/pulser/channels/channels.py @@ -0,0 +1,80 @@ +# Copyright 2020 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Channel subclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from pulser.channels.base_channel import Channel, Literal +from pulser.channels.eom import RydbergEOM + + +@dataclass(init=True, repr=False, frozen=True) +class Raman(Channel): + """Raman beam channel. + + Channel targeting the transition between the hyperfine ground states, in + which the 'digital' basis is encoded. See base class. + """ + + @property + def basis(self) -> Literal["digital"]: + """The addressed basis name.""" + return "digital" + + +@dataclass(init=True, repr=False, frozen=True) +class Rydberg(Channel): + """Rydberg beam channel. + + Channel targeting the transition between the ground and rydberg states, + thus enconding the 'ground-rydberg' basis. See base class. + """ + + eom_config: Optional[RydbergEOM] = None + + def __post_init__(self) -> None: + super().__post_init__() + if self.eom_config is not None: + if not isinstance(self.eom_config, RydbergEOM): + raise TypeError( + "When defined, 'eom_config' must be a valid 'RydbergEOM'" + f" instance, not {type(self.eom_config)}." + ) + if self.mod_bandwidth is None: + raise ValueError( + "'eom_config' can't be defined in a Channel without a " + "modulation bandwidth." + ) + + @property + def basis(self) -> Literal["ground-rydberg"]: + """The addressed basis name.""" + return "ground-rydberg" + + +@dataclass(init=True, repr=False, frozen=True) +class Microwave(Channel): + """Microwave adressing channel. + + Channel targeting the transition between two rydberg states, thus encoding + the 'XY' basis. See base class. + """ + + @property + def basis(self) -> Literal["XY"]: + """The addressed basis name.""" + return "XY" diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py new file mode 100644 index 000000000..783fd20f9 --- /dev/null +++ b/pulser-core/pulser/channels/eom.py @@ -0,0 +1,201 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Configuration parameters for a channel's EOM.""" +from __future__ import annotations + +from dataclasses import dataclass, fields +from enum import Flag +from itertools import chain +from typing import Any, cast + +import numpy as np + +from pulser.json.utils import obj_to_dict + +# Conversion factor from modulation bandwith to rise time +# For more info, see https://tinyurl.com/bdeumc8k +MODBW_TO_TR = 0.48 + + +class RydbergBeam(Flag): + """The beams that make up a Rydberg channel.""" + + BLUE = 1 + RED = 2 + + def _to_dict(self) -> dict[str, Any]: + return obj_to_dict(self, self.value) + + def _to_abstract_repr(self) -> str: + return cast(str, self.name) + + +@dataclass(frozen=True) +class BaseEOM: + """A base class for the EOM configuration. + + Attributes: + mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), + in MHz. + """ + + mod_bandwidth: float # MHz + + def __post_init__(self) -> None: + if self.mod_bandwidth <= 0.0: + raise ValueError( + "'mod_bandwidth' must be greater than zero, not" + f" {self.mod_bandwidth}." + ) + + @property + def rise_time(self) -> int: + """The rise time (in ns). + + Defined as the time taken to go from 10% to 90% output in response to + a step change in the input. + """ + return int(MODBW_TO_TR / self.mod_bandwidth * 1e3) + + def _to_dict(self) -> dict[str, Any]: + params = { + f.name: getattr(self, f.name) for f in fields(self) if f.init + } + return obj_to_dict(self, **params) + + def _to_abstract_repr(self) -> dict[str, Any]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +@dataclass(frozen=True) +class RydbergEOM(BaseEOM): + """The EOM configuration for a Rydberg channel. + + Attributes: + mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), + in MHz. + limiting_beam: The beam with the smallest amplitude range. + max_limiting_amp: The maximum amplitude the limiting beam can reach, + in rad/µs. + intermediate_detuning: The detuning between the two beams, in rad/µs. + controlled_beams: The beams that can be switched on/off with an EOM. + """ + + limiting_beam: RydbergBeam + max_limiting_amp: float # rad/µs + intermediate_detuning: float # rad/µs + controlled_beams: tuple[RydbergBeam, ...] + + def __post_init__(self) -> None: + super().__post_init__() + for param in ["max_limiting_amp", "intermediate_detuning"]: + value = getattr(self, param) + if value <= 0.0: + raise ValueError( + f"'{param}' must be greater than zero, not {value}." + ) + if not isinstance(self.controlled_beams, tuple): + if not isinstance(self.controlled_beams, list): + raise TypeError( + "The 'controlled_beams' must be provided as a tuple " + "or list." + ) + # Convert list to tuple to keep RydbergEOM hashable + object.__setattr__( + self, "controlled_beams", tuple(self.controlled_beams) + ) + if not self.controlled_beams: + raise ValueError( + "There must be at least one beam in 'controlled_beams'." + ) + for beam in chain((self.limiting_beam,), self.controlled_beams): + if not (isinstance(beam, RydbergBeam) and beam in RydbergBeam): + raise TypeError( + "Every beam must be one of options of the `RydbergBeam`" + f" enumeration, not {self.limiting_beam}." + ) + + def detuning_off_options( + self, rabi_frequency: float, detuning_on: float + ) -> np.ndarray: + """Calculates the possible detuning values when the amplitude is off. + + Args: + rabi_frequency: The Rabi frequency when executing a pulse, + in rad/µs. + detuning_on: The detuning when executing a pulse, in rad/µs. + + Returns: + The possible detuning values when in between pulses. + """ + # detuning = offset + lightshift + + # offset takes into account the lightshift when both beams are on + # which is not zero when the Rabi freq of both beams is not equal + offset = detuning_on - self._lightshift(rabi_frequency, *RydbergBeam) + if len(self.controlled_beams) == 1: + # When only one beam is controlled, the lighshift during delays + # corresponds to having only the other beam (which can't be + # switched off) on. + lightshifts = [ + self._lightshift(rabi_frequency, ~self.controlled_beams[0]) + ] + + else: + # When both beams are controlled, we have three options for the + # lightshift: (ON, OFF), (OFF, ON) and (OFF, OFF) + lightshifts = [ + self._lightshift(rabi_frequency, beam) + for beam in self.controlled_beams + ] + # Case where both beams are off ie (OFF, OFF) -> no lightshift + lightshifts.append(0.0) + + # We sum the offset to all lightshifts to get the effective detuning + return np.array(lightshifts) + offset + + def _lightshift( + self, rabi_frequency: float, *beams_on: RydbergBeam + ) -> float: + # lightshift = (rabi_blue**2 - rabi_red**2) / 4 * int_detuning + rabi_freqs = self._rabi_freq_per_beam(rabi_frequency) + bias = {RydbergBeam.RED: -1, RydbergBeam.BLUE: 1} + # beam off -> beam_rabi_freq = 0 + return sum(bias[beam] * rabi_freqs[beam] ** 2 for beam in beams_on) / ( + 4 * self.intermediate_detuning + ) + + def _rabi_freq_per_beam( + self, rabi_frequency: float + ) -> dict[RydbergBeam, float]: + # rabi_freq = (rabi_red * rabi_blue) / (2 * int_detuning) + limit_rabi_freq = self.max_limiting_amp**2 / ( + 2 * self.intermediate_detuning + ) + # limit_rabi_freq is the maximum effective rabi frequency value + # below which the rabi frequency of both beams can be matched + if rabi_frequency <= limit_rabi_freq: + # Both beams the same rabi_freq + beam_amp = np.sqrt(2 * rabi_frequency * self.intermediate_detuning) + return {beam: beam_amp for beam in RydbergBeam} + + # The limiting beam is at its maximum amplitude while the other + # has the necessary amplitude to reach the desired effective rabi freq + return { + self.limiting_beam: self.max_limiting_amp, + ~self.limiting_beam: 2 + * self.intermediate_detuning + * rabi_frequency + / self.max_limiting_amp, + } diff --git a/pulser-core/pulser/devices/__init__.py b/pulser-core/pulser/devices/__init__.py index 066250083..21fa87ea9 100644 --- a/pulser-core/pulser/devices/__init__.py +++ b/pulser-core/pulser/devices/__init__.py @@ -17,10 +17,10 @@ from typing import TYPE_CHECKING -from pulser.devices._device_datacls import Device +from pulser.devices._device_datacls import Device, VirtualDevice from pulser.devices._devices import Chadoq2, IroiseMVP from pulser.devices._mock_device import MockDevice # Registers which devices can be used to avoid definition of custom devices -_mock_devices: tuple[Device, ...] = (MockDevice,) +_mock_devices: tuple[VirtualDevice, ...] = (MockDevice,) _valid_devices: tuple[Device, ...] = (Chadoq2, IroiseMVP) diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index b3ff0c537..e46ec8de2 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -14,23 +14,42 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field, fields +from sys import version_info +from typing import Any, Optional, cast +from warnings import warn import numpy as np from scipy.spatial.distance import pdist, squareform -from pulser.channels import Channel +from pulser.channels.base_channel import Channel from pulser.devices.interaction_coefficients import c6_dict +from pulser.json.abstract_repr.serializer import AbstractReprEncoder from pulser.json.utils import obj_to_dict -from pulser.pulse import Pulse from pulser.register.base_register import BaseRegister, QubitId +from pulser.register.mappable_reg import MappableRegister from pulser.register.register_layout import COORD_PRECISION, RegisterLayout +if version_info[:2] >= (3, 8): # pragma: no cover + from typing import Literal, get_args +else: # pragma: no cover + try: + from typing_extensions import Literal, get_args # type: ignore + except ImportError: + raise ImportError( + "Using pulser with Python version 3.7 requires the" + " `typing_extensions` module. Install it by running" + " `pip install typing-extensions`." + ) -@dataclass(frozen=True, repr=False) -class Device: - r"""Definition of a neutral-atom device. +DIMENSIONS = Literal[2, 3] + + +@dataclass(frozen=True, repr=False) # type: ignore[misc] +class BaseDevice(ABC): + r"""Base class of a neutral-atom device. Attributes: name: The name of the device. @@ -44,27 +63,110 @@ class Device: min_atom_distance: The closest together two atoms can be (in μm). interaction_coeff_xy: :math:`C_3/\hbar` (in :math:`\mu m^3 / \mu s`), which sets the van der Waals interaction strength between atoms in - different Rydberg states. + different Rydberg states. Needed only if there is a Microwave + channel in the device. If unsure, 3700.0 is a good default value. + supports_slm_mask: Whether the device supports the SLM mask feature. + max_layout_filling: The largest fraction of a layout that can be filled + with atoms. """ - name: str - dimensions: int + dimensions: DIMENSIONS rydberg_level: int - max_atom_num: int - max_radial_distance: int - min_atom_distance: int _channels: tuple[tuple[str, Channel], ...] - # Ising interaction coeff - interaction_coeff_xy: float = 3700.0 - pre_calibrated_layouts: tuple[RegisterLayout, ...] = field( - default_factory=tuple - ) + min_atom_distance: float + max_atom_num: Optional[int] + max_radial_distance: Optional[int] + interaction_coeff_xy: Optional[float] = None + supports_slm_mask: bool = False + max_layout_filling: float = 0.5 + reusable_channels: bool = field(default=False, init=False) def __post_init__(self) -> None: - # Hack to override the docstring of an instance - object.__setattr__(self, "__doc__", self._specs(for_docs=True)) - for layout in self.pre_calibrated_layouts: - self.validate_layout(layout) + def type_check( + param: str, type_: type, value_override: Any = None + ) -> None: + value = ( + getattr(self, param) + if value_override is None + else value_override + ) + if not isinstance(value, type_): + raise TypeError( + f"{param} must be of type '{type_.__name__}', " + f"not '{type(value).__name__}'." + ) + + type_check("name", str) + if self.dimensions not in get_args(DIMENSIONS): + raise ValueError( + f"'dimensions' must be one of {get_args(DIMENSIONS)}, " + f"not {self.dimensions}." + ) + self._validate_rydberg_level(self.rydberg_level) + for ch_id, ch_obj in self._channels: + type_check("All channel IDs", str, value_override=ch_id) + type_check("All channels", Channel, value_override=ch_obj) + + for param in ( + "min_atom_distance", + "max_atom_num", + "max_radial_distance", + ): + value = getattr(self, param) + if param in self._optional_parameters: + prelude = "When defined, " + is_none = value is None + elif value is None: + raise TypeError( + f"'{param}' can't be None in a '{type(self).__name__}' " + "instance." + ) + else: + prelude = "" + is_none = False + + if param == "min_atom_distance": + comp = "greater than or equal to zero" + valid = is_none or value >= 0 + else: + if not is_none: + type_check(param, int) + comp = "greater than zero" + valid = is_none or value > 0 + msg = prelude + f"'{param}' must be {comp}, not {value}." + if not valid: + raise ValueError(msg) + + if any( + ch.basis == "XY" for _, ch in self._channels + ) and not isinstance(self.interaction_coeff_xy, float): + raise TypeError( + "When the device has a 'Microwave' channel, " + "'interaction_coeff_xy' must be a 'float'," + f" not '{type(self.interaction_coeff_xy)}'." + ) + type_check("supports_slm_mask", bool) + type_check("reusable_channels", bool) + + if not (0.0 < self.max_layout_filling <= 1.0): + raise ValueError( + "The maximum layout filling fraction must be " + "greater than 0. and less than or equal to 1., " + f"not {self.max_layout_filling}." + ) + + def to_tuple(obj: tuple | list) -> tuple: + if isinstance(obj, (tuple, list)): + obj = tuple(to_tuple(el) for el in obj) + return obj + + # Turns mutable lists into immutable tuples + object.__setattr__(self, "_channels", to_tuple(self._channels)) + + @property + @abstractmethod + def _optional_parameters(self) -> tuple[str, ...]: + pass @property def channels(self) -> dict[str, Channel]: @@ -81,38 +183,9 @@ def interaction_coeff(self) -> float: r""":math:`C_6/\hbar` coefficient of chosen Rydberg level.""" return float(c6_dict[self.rydberg_level]) - @property - def calibrated_register_layouts(self) -> dict[str, RegisterLayout]: - """Register layouts already calibrated on this device.""" - return {str(layout): layout for layout in self.pre_calibrated_layouts} - - def print_specs(self) -> None: - """Prints the device specifications.""" - title = f"{self.name} Specifications" - header = ["-" * len(title), title, "-" * len(title)] - print("\n".join(header)) - print(self._specs()) - def __repr__(self) -> str: return self.name - def change_rydberg_level(self, ryd_lvl: int) -> None: - """Changes the Rydberg level used in the Device. - - Args: - ryd_lvl: the Rydberg level to use (between 50 and 100). - - Note: - Modifications to the `rydberg_level` attribute only affect the - outcomes of local emulations. - """ - if not isinstance(ryd_lvl, int): - raise TypeError("Rydberg level has to be an int.") - if not ((49 < ryd_lvl) & (101 > ryd_lvl)): - raise ValueError("Rydberg level should be between 50 and 100.") - - object.__setattr__(self, "rydberg_level", ryd_lvl) - def rydberg_blockade_radius(self, rabi_frequency: float) -> float: """Calculates the Rydberg blockade radius for a given Rabi frequency. @@ -162,6 +235,7 @@ def validate_register(self, register: BaseRegister) -> None: "The 'register' is associated with an incompatible " "register layout." ) + self.validate_layout_filling(register) def validate_layout(self, layout: RegisterLayout) -> None: """Checks if a register layout is compatible with this device. @@ -180,45 +254,238 @@ def validate_layout(self, layout: RegisterLayout) -> None: self._validate_coords(layout.traps_dict, kind="traps") - def validate_pulse(self, pulse: Pulse, channel_id: str) -> None: - """Checks if a pulse can be executed on a specific device channel. + def validate_layout_filling( + self, register: BaseRegister | MappableRegister + ) -> None: + """Checks if a register properly fills its layout. Args: - pulse: The pulse to validate. - channel_id: The channel ID used to index the chosen channel - on this device. + register: The register to validate. Must be created from a register + layout. """ - if not isinstance(pulse, Pulse): + if register.layout is None: raise TypeError( - f"'pulse' must be of type Pulse, not of type {type(pulse)}." + "'validate_layout_filling' can only be called for" + " registers with a register layout." + ) + n_qubits = len(register.qubit_ids) + max_qubits = int( + register.layout.number_of_traps * self.max_layout_filling + ) + if n_qubits > max_qubits: + raise ValueError( + "Given the number of traps in the layout and the " + "device's maximum layout filling fraction, the given" + f" register has too many qubits ({n_qubits}). " + "On this device, this layout can hold at most " + f"{max_qubits} qubits." ) - ch = self.channels[channel_id] - if np.any(pulse.amplitude.samples > ch.max_amp): + def _validate_atom_number(self, coords: list[np.ndarray]) -> None: + max_atom_num = cast(int, self.max_atom_num) + if len(coords) > max_atom_num: raise ValueError( - "The pulse's amplitude goes over the maximum " - "value allowed for the chosen channel." + f"The number of atoms ({len(coords)})" + " must be less than or equal to the maximum" + f" number of atoms supported by this device" + f" ({max_atom_num})." ) - if np.any( - np.round(np.abs(pulse.detuning.samples), decimals=6) - > ch.max_abs_detuning - ): + + def _validate_atom_distance( + self, ids: list[QubitId], coords: list[np.ndarray], kind: str + ) -> None: + def invalid_dists(dists: np.ndarray) -> np.ndarray: + cond1 = dists - self.min_atom_distance < -( + 10 ** (-COORD_PRECISION) + ) + # Ensures there are no identical traps when + # min_atom_distance = 0 + cond2 = dists < 10 ** (-COORD_PRECISION) + return cast(np.ndarray, np.logical_or(cond1, cond2)) + + if len(coords) > 1: + distances = pdist(coords) # Pairwise distance between atoms + if np.any(invalid_dists(distances)): + sq_dists = squareform(distances) + mask = np.triu(np.ones(len(coords), dtype=bool), k=1) + bad_pairs = np.argwhere( + np.logical_and(invalid_dists(sq_dists), mask) + ) + bad_qbt_pairs = [(ids[i], ids[j]) for i, j in bad_pairs] + raise ValueError( + f"The minimal distance between {kind} in this device " + f"({self.min_atom_distance} µm) is not respected " + f"(up to a precision of 1e{-COORD_PRECISION} µm) " + f"for the pairs: {bad_qbt_pairs}" + ) + + def _validate_radial_distance( + self, ids: list[QubitId], coords: list[np.ndarray], kind: str + ) -> None: + too_far = np.linalg.norm(coords, axis=1) > self.max_radial_distance + if np.any(too_far): raise ValueError( - "The pulse's detuning values go out of the range " - "allowed for the chosen channel." + f"All {kind} must be at most {self.max_radial_distance} μm " + f"away from the center of the array, which is not the case " + f"for: {[ids[int(i)] for i in np.where(too_far)[0]]}" ) + def _validate_rydberg_level(self, ryd_lvl: int) -> None: + if not isinstance(ryd_lvl, int): + raise TypeError("Rydberg level has to be an int.") + if not 49 < ryd_lvl < 101: + raise ValueError("Rydberg level should be between 50 and 100.") + + def _params(self) -> dict[str, Any]: + # This is used instead of dataclasses.asdict() because asdict() + # is recursive and we have Channel dataclasses in the args that + # we don't want to convert to dict + return {f.name: getattr(self, f.name) for f in fields(self)} + + def _validate_coords( + self, coords_dict: dict[QubitId, np.ndarray], kind: str = "atoms" + ) -> None: + ids = list(coords_dict.keys()) + coords = list(coords_dict.values()) + if kind == "atoms" and not ( + "max_atom_num" in self._optional_parameters + and self.max_atom_num is None + ): + self._validate_atom_number(coords) + self._validate_atom_distance(ids, coords, kind) + if not ( + "max_radial_distance" in self._optional_parameters + and self.max_radial_distance is None + ): + self._validate_radial_distance(ids, coords, kind) + + @abstractmethod + def _to_dict(self) -> dict[str, Any]: + pass + + @abstractmethod + def _to_abstract_repr(self) -> dict[str, Any]: + params = self._params() + ch_list = [] + for ch_name, ch_obj in params.pop("_channels"): + ch_list.append(ch_obj._to_abstract_repr(ch_name)) + + return {"version": "1", "channels": ch_list, **params} + + def to_abstract_repr(self) -> str: + """Serializes the Sequence into an abstract JSON object.""" + return json.dumps(self, cls=AbstractReprEncoder) + + +@dataclass(frozen=True, repr=False) +class Device(BaseDevice): + r"""Specifications of a neutral-atom device. + + A Device instance is immutable and must have all of its parameters defined. + For usage in emulations, it can be converted to a VirtualDevice through the + `Device.to_virtual()` method. + + Attributes: + name: The name of the device. + dimensions: Whether it supports 2D or 3D arrays. + rybderg_level : The value of the principal quantum number :math:`n` + when the Rydberg level used is of the form + :math:`|nS_{1/2}, m_j = +1/2\rangle`. + max_atom_num: Maximum number of atoms supported in an array. + max_radial_distance: The furthest away an atom can be from the center + of the array (in μm). + min_atom_distance: The closest together two atoms can be (in μm). + interaction_coeff_xy: :math:`C_3/\hbar` (in :math:`\mu m^3 / \mu s`), + which sets the van der Waals interaction strength between atoms in + different Rydberg states. + supports_slm_mask: Whether the device supports the SLM mask feature. + max_layout_filling: The largest fraction of a layout that can be filled + with atoms. + pre_calibrated_layouts: RegisterLayout instances that are already + available on the Device. + """ + max_atom_num: int + max_radial_distance: int + pre_calibrated_layouts: tuple[RegisterLayout, ...] = field( + default_factory=tuple + ) + + def __post_init__(self) -> None: + super().__post_init__() + for ch_id, ch_obj in self._channels: + if ch_obj.is_virtual(): + _sep = "', '" + raise ValueError( + "A 'Device' instance cannot contain virtual channels." + f" For channel '{ch_id}', please define: " + f"'{_sep.join(ch_obj._undefined_fields())}'" + ) + for layout in self.pre_calibrated_layouts: + self.validate_layout(layout) + # Hack to override the docstring of an instance + object.__setattr__(self, "__doc__", self._specs(for_docs=True)) + + @property + def _optional_parameters(self) -> tuple[str, ...]: + return () + + @property + def calibrated_register_layouts(self) -> dict[str, RegisterLayout]: + """Register layouts already calibrated on this device.""" + return {str(layout): layout for layout in self.pre_calibrated_layouts} + + def change_rydberg_level(self, ryd_lvl: int) -> None: + """Changes the Rydberg level used in the Device. + + Args: + ryd_lvl: the Rydberg level to use (between 50 and 100). + + Note: + Deprecated in version 0.8.0. Convert the device to a VirtualDevice + with 'Device.to_virtual()' and use + 'VirtualDevice.change_rydberg_level()' instead. + """ + warn( + "'Device.change_rydberg_level()' is deprecated and will be removed" + " in version 0.9.0.\nConvert the device to a VirtualDevice with " + "'Device.to_virtual()' and use " + "'VirtualDevice.change_rydberg_level()' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Ignoring type because it expects a VirtualDevice + # Won't fix because this line will be removed + VirtualDevice.change_rydberg_level(self, ryd_lvl) # type: ignore + + def to_virtual(self) -> VirtualDevice: + """Converts the Device into a VirtualDevice.""" + params = self._params() + all_params_names = set(params) + target_params_names = {f.name for f in fields(VirtualDevice)} + for param in all_params_names - target_params_names: + del params[param] + return VirtualDevice(**params) + + def print_specs(self) -> None: + """Prints the device specifications.""" + title = f"{self.name} Specifications" + header = ["-" * len(title), title, "-" * len(title)] + print("\n".join(header)) + print(self._specs()) + def _specs(self, for_docs: bool = False) -> str: lines = [ - "\nRegister requirements:", + "\nRegister parameters:", f" - Dimensions: {self.dimensions}D", - rf" - Rydberg level: {self.rydberg_level}", + f" - Rydberg level: {self.rydberg_level}", f" - Maximum number of atoms: {self.max_atom_num}", f" - Maximum distance from origin: {self.max_radial_distance} μm", ( " - Minimum distance between neighbouring atoms: " f"{self.min_atom_distance} μm" ), + f" - Maximum layout filling fraction: {self.max_layout_filling}", + f" - SLM Mask: {'Yes' if self.supports_slm_mask else 'No'}", "\nChannels:", ] @@ -239,7 +506,6 @@ def _specs(self, for_docs: bool = False) -> str: + r"- Maximum :math:`|\delta|`:" + f" {ch.max_abs_detuning:.4g} rad/µs" ), - f"\t- Phase Jump Time: {ch.phase_jump_time} ns", ] if ch.addressing == "Local": ch_lines += [ @@ -257,47 +523,68 @@ def _specs(self, for_docs: bool = False) -> str: return "\n".join(lines + ch_lines) - def _validate_coords( - self, coords_dict: dict[QubitId, np.ndarray], kind: str = "atoms" - ) -> None: - ids = list(coords_dict.keys()) - coords = list(coords_dict.values()) - max_number = self.max_atom_num * (2 if kind == "traps" else 1) - if len(coords) > max_number: - raise ValueError( - f"The number of {kind} ({len(coords)})" - " must be less than or equal to the maximum" - f" number of {kind} supported by this device" - f" ({max_number})." - ) - - if len(coords) > 1: - distances = pdist(coords) # Pairwise distance between atoms - if np.any( - distances - self.min_atom_distance - < -(10 ** (-COORD_PRECISION)) - ): - sq_dists = squareform(distances) - mask = np.triu(np.ones(len(coords), dtype=bool), k=1) - bad_pairs = np.argwhere( - np.logical_and(sq_dists < self.min_atom_distance, mask) - ) - bad_qbt_pairs = [(ids[i], ids[j]) for i, j in bad_pairs] - raise ValueError( - f"The minimal distance between {kind} in this device " - f"({self.min_atom_distance} µm) is not respected for the " - f"pairs: {bad_qbt_pairs}" - ) - - too_far = np.linalg.norm(coords, axis=1) > self.max_radial_distance - if np.any(too_far): - raise ValueError( - f"All {kind} must be at most {self.max_radial_distance} μm " - f"away from the center of the array, which is not the case " - f"for: {[ids[int(i)] for i in np.where(too_far)[0]]}" - ) - def _to_dict(self) -> dict[str, Any]: return obj_to_dict( self, _build=False, _module="pulser.devices", _name=self.name ) + + def _to_abstract_repr(self) -> dict[str, Any]: + d = super()._to_abstract_repr() + d["is_virtual"] = False + return d + + +@dataclass(frozen=True) +class VirtualDevice(BaseDevice): + r"""Specifications of a virtual neutral-atom device. + + A VirtualDevice can only be used for emulation and allows some parameters + to be left undefined. Furthermore, it optionally allows the same channel + to be declared multiple times in the same Sequence (when + `reusable_channels=True`) and allows the Rydberg level to be changed. + + Attributes: + name: The name of the device. + dimensions: Whether it supports 2D or 3D arrays. + rybderg_level : The value of the principal quantum number :math:`n` + when the Rydberg level used is of the form + :math:`|nS_{1/2}, m_j = +1/2\rangle`. + max_atom_num: Maximum number of atoms supported in an array. + max_radial_distance: The furthest away an atom can be from the center + of the array (in μm). + min_atom_distance: The closest together two atoms can be (in μm). + interaction_coeff_xy: :math:`C_3/\hbar` (in :math:`\mu m^3 / \mu s`), + which sets the van der Waals interaction strength between atoms in + different Rydberg states. + supports_slm_mask: Whether the device supports the SLM mask feature. + max_layout_filling: The largest fraction of a layout that can be filled + with atoms. + reusable_channels: Whether each channel can be declared multiple times + on the same pulse sequence. + """ + min_atom_distance: float = 0 + max_atom_num: Optional[int] = None + max_radial_distance: Optional[int] = None + supports_slm_mask: bool = True + reusable_channels: bool = True + + @property + def _optional_parameters(self) -> tuple[str, ...]: + return ("max_atom_num", "max_radial_distance") + + def change_rydberg_level(self, ryd_lvl: int) -> None: + """Changes the Rydberg level used in the Device. + + Args: + ryd_lvl: the Rydberg level to use (between 50 and 100). + """ + self._validate_rydberg_level(ryd_lvl) + object.__setattr__(self, "rydberg_level", ryd_lvl) + + def _to_dict(self) -> dict[str, Any]: + return obj_to_dict(self, _module="pulser.devices", **self._params()) + + def _to_abstract_repr(self) -> dict[str, Any]: + d = super()._to_abstract_repr() + d["is_virtual"] = True + return d diff --git a/pulser-core/pulser/devices/_devices.py b/pulser-core/pulser/devices/_devices.py index 0c9dd81c6..ef292066d 100644 --- a/pulser-core/pulser/devices/_devices.py +++ b/pulser-core/pulser/devices/_devices.py @@ -15,6 +15,7 @@ import numpy as np from pulser.channels import Raman, Rydberg +from pulser.channels.eom import RydbergBeam, RydbergEOM from pulser.devices._device_datacls import Device Chadoq2 = Device( @@ -24,10 +25,44 @@ max_atom_num=100, max_radial_distance=50, min_atom_distance=4, + supports_slm_mask=True, _channels=( - ("rydberg_global", Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5)), - ("rydberg_local", Rydberg.Local(2 * np.pi * 20, 2 * np.pi * 10)), - ("raman_local", Raman.Local(2 * np.pi * 20, 2 * np.pi * 10)), + ( + "rydberg_global", + Rydberg.Global( + max_abs_detuning=2 * np.pi * 20, + max_amp=2 * np.pi * 2.5, + clock_period=4, + min_duration=16, + max_duration=2**26, + ), + ), + ( + "rydberg_local", + Rydberg.Local( + max_abs_detuning=2 * np.pi * 20, + max_amp=2 * np.pi * 10, + min_retarget_interval=220, + fixed_retarget_t=0, + max_targets=1, + clock_period=4, + min_duration=16, + max_duration=2**26, + ), + ), + ( + "raman_local", + Raman.Local( + max_abs_detuning=2 * np.pi * 20, + max_amp=2 * np.pi * 10, + min_retarget_interval=220, + fixed_retarget_t=0, + max_targets=1, + clock_period=4, + min_duration=16, + max_duration=2**26, + ), + ), ), ) @@ -44,7 +79,17 @@ Rydberg.Global( max_abs_detuning=2 * np.pi * 4, max_amp=2 * np.pi * 3, - phase_jump_time=500, + clock_period=4, + min_duration=16, + max_duration=2**26, + mod_bandwidth=4, + eom_config=RydbergEOM( + limiting_beam=RydbergBeam.RED, + max_limiting_amp=40 * 2 * np.pi, + intermediate_detuning=700 * 2 * np.pi, + mod_bandwidth=24, + controlled_beams=(RydbergBeam.BLUE,), + ), ), ), ), diff --git a/pulser-core/pulser/devices/_mock_device.py b/pulser-core/pulser/devices/_mock_device.py index 01bf385e5..f560f9299 100644 --- a/pulser-core/pulser/devices/_mock_device.py +++ b/pulser-core/pulser/devices/_mock_device.py @@ -13,49 +13,22 @@ # limitations under the License. from pulser.channels import Microwave, Raman, Rydberg -from pulser.devices._device_datacls import Device +from pulser.devices._device_datacls import VirtualDevice -MockDevice = Device( +MockDevice = VirtualDevice( name="MockDevice", dimensions=3, rydberg_level=70, - max_atom_num=2000, - max_radial_distance=1000, - min_atom_distance=1, + max_atom_num=None, + max_radial_distance=None, + min_atom_distance=0.0, + interaction_coeff_xy=3700.0, + supports_slm_mask=True, _channels=( - ( - "rydberg_global", - Rydberg.Global(1000, 200, clock_period=1, min_duration=1), - ), - ( - "rydberg_local", - Rydberg.Local( - 1000, - 200, - min_retarget_interval=0, - max_targets=2000, - clock_period=1, - min_duration=1, - ), - ), - ( - "raman_global", - Raman.Global(1000, 200, clock_period=1, min_duration=1), - ), - ( - "raman_local", - Raman.Local( - 1000, - 200, - min_retarget_interval=0, - max_targets=2000, - clock_period=1, - min_duration=1, - ), - ), - ( - "mw_global", - Microwave.Global(1000, 200, clock_period=1, min_duration=1), - ), + ("rydberg_global", Rydberg.Global(None, None, max_duration=None)), + ("rydberg_local", Rydberg.Local(None, None, max_duration=None)), + ("raman_global", Raman.Global(None, None, max_duration=None)), + ("raman_local", Raman.Local(None, None, max_duration=None)), + ("mw_global", Microwave.Global(None, None, max_duration=None)), ), ) diff --git a/pulser-core/pulser/json/abstract_repr/deserializer.py b/pulser-core/pulser/json/abstract_repr/deserializer.py index 59f576e6b..934e9f576 100644 --- a/pulser-core/pulser/json/abstract_repr/deserializer.py +++ b/pulser-core/pulser/json/abstract_repr/deserializer.py @@ -14,14 +14,20 @@ """Deserializer from JSON in the abstract representation.""" from __future__ import annotations +import dataclasses import json from pathlib import Path -from typing import TYPE_CHECKING, Any, Union, cast, overload +from typing import TYPE_CHECKING, Any, Type, Union, cast, overload import jsonschema import pulser import pulser.devices as devices +from pulser.channels import Microwave, Raman, Rydberg +from pulser.channels.base_channel import Channel +from pulser.channels.eom import RydbergBeam, RydbergEOM +from pulser.devices import Device, VirtualDevice +from pulser.devices._device_datacls import BaseDevice from pulser.json.abstract_repr.signatures import ( BINARY_OPERATORS, UNARY_OPERATORS, @@ -43,17 +49,26 @@ Waveform, ) -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.register.base_register import BaseRegister from pulser.sequence import Sequence -with open(Path(__file__).parent / "schema.json") as f: - schema = json.load(f) VARIABLE_TYPE_MAP = {"int": int, "float": float} ExpReturnType = Union[int, float, ParamObj] +schemas_path = Path(__file__).parent / "schemas" +schemas = {} +for obj_type in ("device", "sequence"): + with open(schemas_path / f"{obj_type}-schema.json") as f: + schemas[obj_type] = json.load(f) + +resolver = jsonschema.validators.RefResolver( + base_uri=f"{schemas_path.resolve().as_uri()}/", + referrer=schemas["sequence"], +) + @overload def _deserialize_parameter(param: int, vars: dict[str, Variable]) -> int: @@ -218,6 +233,82 @@ def _deserialize_operation(seq: Sequence, op: dict, vars: dict) -> None: channel=op["channel"], protocol=op["protocol"], ) + elif op["op"] == "enable_eom_mode": + seq.enable_eom_mode( + channel=op["channel"], + amp_on=_deserialize_parameter(op["amp_on"], vars), + detuning_on=_deserialize_parameter(op["detuning_on"], vars), + optimal_detuning_off=_deserialize_parameter( + op["optimal_detuning_off"], vars + ), + ) + elif op["op"] == "add_eom_pulse": + seq.add_eom_pulse( + channel=op["channel"], + duration=_deserialize_parameter(op["duration"], vars), + phase=_deserialize_parameter(op["phase"], vars), + post_phase_shift=_deserialize_parameter( + op["post_phase_shift"], vars + ), + protocol=op["protocol"], + ) + elif op["op"] == "disable_eom_mode": + seq.disable_eom_mode(channel=op["channel"]) + + +def _deserialize_channel(obj: dict[str, Any]) -> Channel: + params: dict[str, Any] = {} + channel_cls: Type[Channel] + if obj["basis"] == "ground-rydberg": + channel_cls = Rydberg + params["eom_config"] = None + if obj["eom_config"] is not None: + data = obj["eom_config"] + params["eom_config"] = RydbergEOM( + mod_bandwidth=data["mod_bandwidth"], + limiting_beam=RydbergBeam[data["limiting_beam"]], + max_limiting_amp=data["max_limiting_amp"], + intermediate_detuning=data["intermediate_detuning"], + controlled_beams=tuple( + RydbergBeam[beam] for beam in data["controlled_beams"] + ), + ) + elif obj["basis"] == "digital": + channel_cls = Raman + elif obj["basis"] == "XY": + channel_cls = Microwave + + for param in dataclasses.fields(channel_cls): + if param.init and param.name != "eom_config": + params[param.name] = obj[param.name] + return channel_cls(**params) + + +def _deserialize_layout(layout_obj: dict[str, Any]) -> RegisterLayout: + return RegisterLayout( + layout_obj["coordinates"], slug=layout_obj.get("slug") + ) + + +def _deserialize_device_object(obj: dict[str, Any]) -> Device | VirtualDevice: + device_cls: Type[Device] | Type[VirtualDevice] = ( + VirtualDevice if obj["is_virtual"] else Device + ) + _channels = tuple( + (ch["id"], _deserialize_channel(ch)) for ch in obj["channels"] + ) + params: dict[str, Any] = {"_channels": _channels} + for param in dataclasses.fields(device_cls): + if not param.init or param.name == "_channels": + continue + if param.name == "pre_calibrated_layouts": + key = "pre_calibrated_layouts" + params[key] = tuple( + _deserialize_layout(layout) for layout in obj[key] + ) + else: + params[param.name] = obj[param.name] + return device_cls(**params) def deserialize_abstract_sequence(obj_str: str) -> Sequence: @@ -233,18 +324,19 @@ def deserialize_abstract_sequence(obj_str: str) -> Sequence: obj = json.loads(obj_str) # Validate the format of the data against the JSON schema. - jsonschema.validate(instance=obj, schema=schema) + jsonschema.validate( + instance=obj, schema=schemas["sequence"], resolver=resolver + ) # Device - device_name = obj["device"] - device = getattr(devices, device_name) + if isinstance(obj["device"], str): + device_name = obj["device"] + device = getattr(devices, device_name) + else: + device = _deserialize_device_object(obj["device"]) # Register Layout - layout = ( - RegisterLayout(obj["layout"]["coordinates"]) - if "layout" in obj - else None - ) + layout = _deserialize_layout(obj["layout"]) if "layout" in obj else None # Register reg: Union[BaseRegister, MappableRegister] @@ -298,3 +390,19 @@ def deserialize_abstract_sequence(obj_str: str) -> Sequence: seq.measure(obj["measurement"]) return seq + + +def deserialize_device(obj_str: str) -> BaseDevice: + """Deserialize a device from an abstract JSON object. + + Args: + obj_str: the JSON string representing the device encoded + in the abstract JSON format. + + Returns: + BaseDevice: The Pulser device. + """ + obj = json.loads(obj_str) + # Validate the format of the data against the JSON schema. + jsonschema.validate(instance=obj, schema=schemas["device"]) + return _deserialize_device_object(obj) diff --git a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json new file mode 100644 index 000000000..0bcfaffdd --- /dev/null +++ b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json @@ -0,0 +1,1297 @@ +{ + "$id": "device-schema.json", + "$ref": "#/definitions/Device", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ChannelId": { + "description": "Hardware channel ID in the Device.", + "type": "string" + }, + "Device": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "channels": { + "description": "The available channels on the device.", + "items": { + "$ref": "#/definitions/PhysicalChannel" + }, + "type": "array" + }, + "dimensions": { + "description": "The maximum dimension of the supported trap arrays.", + "enum": [ + 2, + 3 + ], + "type": "number" + }, + "interaction_coeff_xy": { + "description": "Coefficient setting the interaction stregth between atoms in different Rydberg states. Needed only if the device has a Microwave channel (otherwise can be null).", + "type": [ + "number", + "null" + ] + }, + "is_virtual": { + "const": false, + "description": "Marks the device as physical (ie non-virtual).", + "type": "boolean" + }, + "max_atom_num": { + "description": "Maximum number of atoms supported.", + "type": "number" + }, + "max_layout_filling": { + "description": "The largest fraction of a layout that can be filled with atoms.", + "type": "number" + }, + "max_radial_distance": { + "description": "Maximum distance an atom can be from the center of the array (in µm).", + "type": "number" + }, + "min_atom_distance": { + "description": "The closest together two atoms can be (in μm).", + "type": "number" + }, + "name": { + "description": "A unique name for the device.", + "type": "string" + }, + "pre_calibrated_layouts": { + "description": "Register layouts already calibrated on the device.", + "items": { + "$ref": "#/definitions/Layout" + }, + "type": "array" + }, + "reusable_channels": { + "const": false, + "description": "Whether each channel can be declared multiple times on the same pulse sequence.", + "type": "boolean" + }, + "rydberg_level": { + "description": "The principal quantum number of the used Rydberg level.", + "type": "number" + }, + "supports_slm_mask": { + "description": "Whether the device has an SLM mask.", + "type": "boolean" + }, + "version": { + "const": "1", + "type": "string" + } + }, + "required": [ + "channels", + "dimensions", + "interaction_coeff_xy", + "is_virtual", + "max_atom_num", + "max_layout_filling", + "max_radial_distance", + "min_atom_distance", + "name", + "pre_calibrated_layouts", + "reusable_channels", + "rydberg_level", + "supports_slm_mask", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "channels": { + "description": "The available channels on the device.", + "items": { + "$ref": "#/definitions/GenericChannel" + }, + "type": "array" + }, + "dimensions": { + "description": "The maximum dimension of the supported trap arrays.", + "enum": [ + 2, + 3 + ], + "type": "number" + }, + "interaction_coeff_xy": { + "description": "Coefficient setting the interaction stregth between atoms in different Rydberg states. Needed only if the device has a Microwave channel (otherwise can be null).", + "type": [ + "number", + "null" + ] + }, + "is_virtual": { + "const": true, + "description": "Marks the device as virtual.", + "type": "boolean" + }, + "max_atom_num": { + "description": "Maximum number of atoms supported.", + "type": [ + "number", + "null" + ] + }, + "max_layout_filling": { + "description": "The largest fraction of a layout that can be filled with atoms.", + "type": "number" + }, + "max_radial_distance": { + "description": "Maximum distance an atom can be from the center of the array (in µm).", + "type": [ + "number", + "null" + ] + }, + "min_atom_distance": { + "description": "The closest together two atoms can be (in μm).", + "type": "number" + }, + "name": { + "description": "A unique name for the device.", + "type": "string" + }, + "reusable_channels": { + "description": "Whether each channel can be declared multiple times on the same pulse sequence.", + "type": "boolean" + }, + "rydberg_level": { + "description": "The principal quantum number of the used Rydberg level.", + "type": "number" + }, + "supports_slm_mask": { + "description": "Whether the device has an SLM mask.", + "type": "boolean" + }, + "version": { + "const": "1", + "type": "string" + } + }, + "required": [ + "channels", + "dimensions", + "interaction_coeff_xy", + "is_virtual", + "max_atom_num", + "max_layout_filling", + "max_radial_distance", + "min_atom_distance", + "name", + "reusable_channels", + "rydberg_level", + "supports_slm_mask", + "version" + ], + "type": "object" + } + ] + }, + "GenericChannel": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "ground-rydberg", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/RydbergEOM" + } + ], + "description": "Configuration of an associated EOM." + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "digital", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "XY", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "ground-rydberg", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/RydbergEOM" + } + ], + "description": "Configuration of an associated EOM." + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": [ + "number", + "null" + ] + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "digital", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": [ + "number", + "null" + ] + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "XY", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": [ + "number", + "null" + ] + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": [ + "number", + "null" + ] + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": [ + "number", + "null" + ] + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": [ + "number", + "null" + ] + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + } + ], + "description": "A Channel that can be physical or virtual." + }, + "Layout": { + "additionalProperties": false, + "description": "Layout with the positions of the traps. A selection of up to 50% of these traps makes up the Register.", + "properties": { + "coordinates": { + "description": "The trap coordinates in µm.", + "items": { + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "type": "array" + }, + "slug": { + "description": "An optional name for the layout.", + "type": "string" + } + }, + "required": [ + "coordinates" + ], + "type": "object" + }, + "PhysicalChannel": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "ground-rydberg", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/RydbergEOM" + } + ], + "description": "Configuration of an associated EOM." + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "digital", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Global", + "type": "string" + }, + "basis": { + "const": "XY", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "null" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "null" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "null" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "ground-rydberg", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/RydbergEOM" + } + ], + "description": "Configuration of an associated EOM." + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "number" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "digital", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "number" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "addressing": { + "const": "Local", + "type": "string" + }, + "basis": { + "const": "XY", + "description": "The addressed basis name.", + "type": "string" + }, + "clock_period": { + "description": "The duration of a clock cycle (in ns).", + "type": "number" + }, + "eom_config": { + "description": "Configuration of an associated EOM.", + "type": "null" + }, + "fixed_retarget_t": { + "description": "Time taken to change the target (in ns).", + "type": "number" + }, + "id": { + "$ref": "#/definitions/ChannelId", + "description": "The identifier of the channel within its device." + }, + "max_abs_detuning": { + "description": "Maximum possible detuning (in rad/µs), in absolute value.", + "type": "number" + }, + "max_amp": { + "description": "Maximum pulse amplitude (in rad/µs).", + "type": "number" + }, + "max_duration": { + "description": "The longest duration an instruction can take.", + "type": "number" + }, + "max_targets": { + "description": "How many atoms can be locally addressed at once by the same beam.", + "type": "number" + }, + "min_duration": { + "description": "The shortest duration an instruction can take.", + "type": "number" + }, + "min_retarget_interval": { + "description": "Minimum time required between the ends of two target instructions (in ns).", + "type": "number" + }, + "mod_bandwidth": { + "description": "The modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "addressing", + "basis", + "clock_period", + "eom_config", + "fixed_retarget_t", + "id", + "max_abs_detuning", + "max_amp", + "max_duration", + "max_targets", + "min_duration", + "min_retarget_interval", + "mod_bandwidth" + ], + "type": "object" + } + ] + }, + "RydbergBeam": { + "enum": [ + "RED", + "BLUE" + ], + "type": "string" + }, + "RydbergEOM": { + "additionalProperties": false, + "properties": { + "controlled_beams": { + "description": "The beams that can be switched on/off with an EOM.", + "items": { + "$ref": "#/definitions/RydbergBeam" + }, + "type": "array" + }, + "intermediate_detuning": { + "description": "The detuning between the two beams, in rad/µs.", + "type": "number" + }, + "limiting_beam": { + "$ref": "#/definitions/RydbergBeam", + "description": "The beam with the smallest amplitude range." + }, + "max_limiting_amp": { + "description": "The maximum amplitude the limiting beam can reach, in rad/µs.", + "type": "number" + }, + "mod_bandwidth": { + "description": "The EOM modulation bandwidth at -3dB (50% reduction), in MHz.", + "type": "number" + } + }, + "required": [ + "mod_bandwidth", + "limiting_beam", + "max_limiting_amp", + "intermediate_detuning", + "controlled_beams" + ], + "type": "object" + } + } +} diff --git a/pulser-core/pulser/json/abstract_repr/schema.json b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json similarity index 87% rename from pulser-core/pulser/json/abstract_repr/schema.json rename to pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json index dad62945c..ee0153c88 100644 --- a/pulser-core/pulser/json/abstract_repr/schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json @@ -26,6 +26,7 @@ "type": "object" }, "Basis": { + "description": "The two-level-system basis addressable by a given channel.", "enum": [ "ground-rydberg", "digital", @@ -81,6 +82,10 @@ ], "type": "object" }, + "ChannelId": { + "description": "Hardware channel ID in the Device.", + "type": "string" + }, "ChannelName": { "description": "Name of declared channel.", "type": "string" @@ -152,12 +157,7 @@ "type": "object" }, "Device": { - "enum": [ - "Chadoq2", - "IroiseMVP", - "MockDevice" - ], - "type": "string" + "$ref": "device-schema.json" }, "ExprArgument": { "anyOf": [ @@ -259,14 +259,11 @@ ], "description": "Mathematical expression involving variables and constants.\n\nThe expression is evaluated in the context of any parametrizable field.\n\nIf the context requires an integer value, the float result is rounded at the end. If the expression type differs from expected by the context (e.g. channel_name), it is a runtime error. If an expression result array length differs from expected, a it is a runtime error." }, - "HardwareChannel": { - "description": "Hardware channel name.", + "HardcodedDevice": { "enum": [ - "raman_global", - "raman_local", - "rydberg_local", - "rydberg_global", - "mw_global" + "Chadoq2", + "IroiseMVP", + "MockDevice" ], "type": "string" }, @@ -445,6 +442,100 @@ ], "type": "object" }, + "OpDisableEOM": { + "additionalProperties": false, + "properties": { + "channel": { + "$ref": "#/definitions/ChannelName", + "description": "The name of the channel to take out of EOM mode." + }, + "op": { + "const": "disable_eom_mode", + "type": "string" + } + }, + "required": [ + "op", + "channel" + ], + "type": "object" + }, + "OpEOMPulse": { + "additionalProperties": false, + "properties": { + "channel": { + "$ref": "#/definitions/ChannelName", + "description": "The name of the channel to add the pulse to." + }, + "duration": { + "$ref": "#/definitions/ParametrizedNum", + "description": "The duration of the pulse (in ns)." + }, + "op": { + "const": "add_eom_pulse", + "type": "string" + }, + "phase": { + "$ref": "#/definitions/ParametrizedNum", + "description": "The pulse phase (in radians)." + }, + "post_phase_shift": { + "$ref": "#/definitions/ParametrizedNum", + "description": "A phase shift (in radians) immediately after the end of the pulse." + }, + "protocol": { + "description": "Stipulates how to deal with eventual conflicts with other channels.", + "enum": [ + "min-delay", + "no-delay", + "wait-for-all" + ], + "type": "string" + } + }, + "required": [ + "op", + "channel", + "duration", + "phase", + "post_phase_shift", + "protocol" + ], + "type": "object" + }, + "OpEnableEOM": { + "additionalProperties": false, + "properties": { + "amp_on": { + "$ref": "#/definitions/ParametrizedNum", + "description": "The amplitude of the EOM pulses (in rad/µs)." + }, + "channel": { + "$ref": "#/definitions/ChannelName", + "description": "The name of the channel to put in EOM mode." + }, + "detuning_on": { + "$ref": "#/definitions/ParametrizedNum", + "description": "The detuning of the EOM pulses (in rad/µs)." + }, + "op": { + "const": "enable_eom_mode", + "type": "string" + }, + "optimal_detuning_off": { + "$ref": "#/definitions/ParametrizedNum", + "description": "The optimal value of detuning when there is no pulse being played (in rad/µs). It will choose the closest value among the existing options." + } + }, + "required": [ + "op", + "channel", + "amp_on", + "detuning_on", + "optimal_detuning_off" + ], + "type": "object" + }, "OpPhaseShift": { "additionalProperties": false, "description": "Adds a separate phase shift to atoms. If possible, OpPulse phase and post_phase_shift are preferred.", @@ -566,6 +657,15 @@ }, { "$ref": "#/definitions/OpPhaseShift" + }, + { + "$ref": "#/definitions/OpEnableEOM" + }, + { + "$ref": "#/definitions/OpDisableEOM" + }, + { + "$ref": "#/definitions/OpEOMPulse" } ], "description": "Sequence operation. All operations are performed in specified order." @@ -608,13 +708,20 @@ }, "channels": { "additionalProperties": { - "$ref": "#/definitions/HardwareChannel" + "$ref": "#/definitions/ChannelId" }, "description": "Channels declared in this Sequence.", "type": "object" }, "device": { - "$ref": "#/definitions/Device", + "anyOf": [ + { + "$ref": "#/definitions/HardcodedDevice" + }, + { + "$ref": "#/definitions/Device" + } + ], "description": "A valid device in which to execute the Sequence" }, "layout": { @@ -698,13 +805,20 @@ }, "channels": { "additionalProperties": { - "$ref": "#/definitions/HardwareChannel" + "$ref": "#/definitions/ChannelId" }, "description": "Channels declared in this Sequence.", "type": "object" }, "device": { - "$ref": "#/definitions/Device", + "anyOf": [ + { + "$ref": "#/definitions/HardcodedDevice" + }, + { + "$ref": "#/definitions/Device" + } + ], "description": "A valid device in which to execute the Sequence" }, "layout": { diff --git a/pulser-core/pulser/json/abstract_repr/serializer.py b/pulser-core/pulser/json/abstract_repr/serializer.py index 9a93a632f..437096c9b 100644 --- a/pulser-core/pulser/json/abstract_repr/serializer.py +++ b/pulser-core/pulser/json/abstract_repr/serializer.py @@ -26,7 +26,7 @@ from pulser.json.exceptions import AbstractReprError from pulser.register.base_register import QubitId -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.sequence import Sequence from pulser.sequence._call import _Call @@ -163,7 +163,7 @@ def get_all_args( for call in chain(seq._calls, seq._to_build_calls): if call.name == "__init__": data = get_all_args(("register", "device"), call) - res["device"] = data["device"].name + res["device"] = data["device"] res["register"] = data["register"] layout = data["register"].layout if layout is not None: @@ -252,6 +252,33 @@ def get_all_args( res["magnetic_field"] = seq.magnetic_field.tolist() elif call.name == "config_slm_mask": res["slm_mask_targets"] = tuple(seq._slm_mask_targets) + elif call.name == "enable_eom_mode": + data = get_all_args( + ("channel", "amp_on", "detuning_on", "optimal_detuning_off"), + call, + ) + # Overwritten if in 'data' + defaults = dict(optimal_detuning_off=0.0) + operations.append({"op": "enable_eom_mode", **defaults, **data}) + elif call.name == "add_eom_pulse": + data = get_all_args( + ( + "channel", + "duration", + "phase", + "post_phase_shift", + "protocol", + ), + call, + ) + # Overwritten if in 'data' + defaults = dict(post_phase_shift=0.0, protocol="min-delay") + operations.append({"op": "add_eom_pulse", **defaults, **data}) + elif call.name == "disable_eom_mode": + data = get_all_args(("channel",), call) + operations.append( + {"op": "disable_eom_mode", "channel": data["channel"]} + ) else: raise AbstractReprError(f"Unknown call '{call.name}'.") diff --git a/pulser-core/pulser/json/abstract_repr/signatures.py b/pulser-core/pulser/json/abstract_repr/signatures.py index 0708fb63e..fc03b0e7c 100644 --- a/pulser-core/pulser/json/abstract_repr/signatures.py +++ b/pulser-core/pulser/json/abstract_repr/signatures.py @@ -21,7 +21,7 @@ import numpy as np -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.parametrized.variable import Variable, VariableItem diff --git a/pulser-core/pulser/json/supported.py b/pulser-core/pulser/json/supported.py index cc8b56126..2b5338478 100644 --- a/pulser-core/pulser/json/supported.py +++ b/pulser-core/pulser/json/supported.py @@ -18,6 +18,7 @@ from typing import Any, Mapping import pulser.devices as devices +from pulser.channels.base_channel import CH_TYPE, get_args from pulser.json.exceptions import SerializationError SUPPORTED_BUILTINS = ("float", "int", "str", "set") @@ -70,8 +71,10 @@ ), "pulser.register.mappable_reg": ("MappableRegister",), "pulser.devices": tuple( - [dev.name for dev in devices._valid_devices] + ["MockDevice"] + [dev.name for dev in devices._valid_devices] + ["VirtualDevice"] ), + "pulser.channels": tuple(get_args(CH_TYPE)), + "pulser.channels.eom": ("RydbergEOM", "RydbergBeam"), "pulser.pulse": ("Pulse",), "pulser.waveforms": ( "CompositeWaveform", diff --git a/pulser-core/pulser/parametrized/paramobj.py b/pulser-core/pulser/parametrized/paramobj.py index 53a2c7743..3a7b887d1 100644 --- a/pulser-core/pulser/parametrized/paramobj.py +++ b/pulser-core/pulser/parametrized/paramobj.py @@ -35,7 +35,7 @@ from pulser.parametrized import Parametrized if TYPE_CHECKING: - from pulser.parametrized import Variable # pragma: no cover + from pulser.parametrized import Variable class OpSupport: diff --git a/pulser-core/pulser/pulse.py b/pulser-core/pulser/pulse.py index c95258707..0245f4fd1 100644 --- a/pulser-core/pulser/pulse.py +++ b/pulser-core/pulser/pulse.py @@ -18,18 +18,21 @@ import functools import itertools from dataclasses import dataclass, field -from typing import Any, Union, cast +from typing import TYPE_CHECKING, Any, Union, cast import matplotlib.pyplot as plt import numpy as np -from pulser.channels import Channel +import pulser from pulser.json.abstract_repr.serializer import abstract_repr from pulser.json.utils import obj_to_dict from pulser.parametrized import Parametrized, ParamObj from pulser.parametrized.decorators import parametrize from pulser.waveforms import ConstantWaveform, Waveform +if TYPE_CHECKING: + from pulser.channels.base_channel import Channel + @dataclass(init=False, repr=False, frozen=True) class Pulse: @@ -188,12 +191,18 @@ def draw(self) -> None: fig.tight_layout() plt.show() - def fall_time(self, channel: Channel) -> int: + def fall_time(self, channel: Channel, in_eom_mode: bool = False) -> int: """Calculates the extra time needed to ramp down to zero.""" - aligned_start_extra_time = channel.rise_time + aligned_start_extra_time = ( + channel.rise_time + if not in_eom_mode + else cast( + pulser.channels.eom.BaseEOM, channel.eom_config + ).rise_time + ) end_extra_time = max( - self.amplitude.modulation_buffers(channel)[1], - self.detuning.modulation_buffers(channel)[1], + self.amplitude.modulation_buffers(channel, eom=in_eom_mode)[1], + self.detuning.modulation_buffers(channel, eom=in_eom_mode)[1], ) return aligned_start_extra_time + end_extra_time diff --git a/pulser-core/pulser/register/base_register.py b/pulser-core/pulser/register/base_register.py index 1506cff57..1062bf25d 100644 --- a/pulser-core/pulser/register/base_register.py +++ b/pulser-core/pulser/register/base_register.py @@ -34,7 +34,7 @@ from pulser.json.utils import obj_to_dict -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.register.register_layout import RegisterLayout T = TypeVar("T", bound="BaseRegister") diff --git a/pulser-core/pulser/register/mappable_reg.py b/pulser-core/pulser/register/mappable_reg.py index af54c4c70..c3eb22697 100644 --- a/pulser-core/pulser/register/mappable_reg.py +++ b/pulser-core/pulser/register/mappable_reg.py @@ -20,7 +20,7 @@ from pulser.json.utils import obj_to_dict, stringify_qubit_ids -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.register.base_register import BaseRegister, QubitId from pulser.register.register_layout import RegisterLayout @@ -38,11 +38,10 @@ class MappableRegister: def __init__(self, register_layout: RegisterLayout, *qubit_ids: QubitId): """Initializes the mappable register.""" self._layout = register_layout - if len(qubit_ids) > self._layout.max_atom_num: + if len(qubit_ids) > self._layout.number_of_traps: raise ValueError( - "The number of required traps is greater than the maximum " - "number of qubits allowed for this layout " - f"({self._layout.max_atom_num})." + "The number of required qubits is greater than the number of " + f"traps in this layout ({self._layout.number_of_traps})." ) self._qubit_ids = qubit_ids diff --git a/pulser-core/pulser/register/register.py b/pulser-core/pulser/register/register.py index c03933bdf..382686170 100644 --- a/pulser-core/pulser/register/register.py +++ b/pulser-core/pulser/register/register.py @@ -209,7 +209,7 @@ def hexagon( def max_connectivity( cls, n_qubits: int, - device: pulser.devices._device_datacls.Device, + device: pulser.devices._device_datacls.BaseDevice, spacing: float = None, prefix: str = None, ) -> Register: @@ -233,11 +233,8 @@ def max_connectivity( A register with qubits placed for maximum connectivity. """ # Check device - if not isinstance(device, pulser.devices._device_datacls.Device): - raise TypeError( - "'device' must be of type 'Device'. Import a valid" - " device from 'pulser.devices'." - ) + if not isinstance(device, pulser.devices._device_datacls.BaseDevice): + raise TypeError("'device' must be of type 'BaseDevice'.") # Check number of qubits (1 or above) if n_qubits < 1: @@ -247,7 +244,7 @@ def max_connectivity( ) # Check number of qubits (less than the max number of atoms) - if n_qubits > device.max_atom_num: + if device.max_atom_num is not None and n_qubits > device.max_atom_num: raise ValueError( f"The number of qubits (`n_qubits` = {n_qubits})" " must be less than or equal to the maximum" @@ -255,6 +252,11 @@ def max_connectivity( f" ({device.max_atom_num})." ) + if not device.min_atom_distance > 0.0: + raise NotImplementedError( + "Maximum connectivity layouts are not well defined for a " + f"device with 'min_atom_distance={device.min_atom_distance}'." + ) # Default spacing or check minimal distance if spacing is None: spacing = device.min_atom_distance diff --git a/pulser-core/pulser/register/register_layout.py b/pulser-core/pulser/register/register_layout.py index 6daaef810..f30c3f9ff 100644 --- a/pulser-core/pulser/register/register_layout.py +++ b/pulser-core/pulser/register/register_layout.py @@ -20,6 +20,7 @@ from hashlib import sha256 from sys import version_info from typing import Any, Optional, cast +from warnings import warn import matplotlib.pyplot as plt import numpy as np @@ -47,7 +48,7 @@ COORD_PRECISION = 6 -@dataclass(repr=False, eq=False, frozen=True) +@dataclass(init=False, repr=False, eq=False, frozen=True) class RegisterLayout(RegDrawer): """A layout of traps out of which registers can be defined. @@ -57,20 +58,35 @@ class RegisterLayout(RegDrawer): Args: trap_coordinates: The trap coordinates defining the layout. + slug: An optional identifier for the layout. """ - trap_coordinates: ArrayLike + _trap_coordinates: ArrayLike + slug: Optional[str] + + def __init__( + self, trap_coordinates: ArrayLike, slug: Optional[str] = None + ): + """Initializes a RegisterLayout.""" + array_type_error_msg = ValueError( + "'trap_coordinates' must be an array or list of coordinates." + ) + + try: + shape = np.array(trap_coordinates).shape + # Following lines are only being covered starting Python 3.11.1 + except ValueError as e: # pragma: no cover + raise array_type_error_msg from e # pragma: no cover - def __post_init__(self) -> None: - shape = np.array(self.trap_coordinates).shape if len(shape) != 2: - raise ValueError( - "'trap_coordinates' must be an array or list of coordinates." - ) + raise array_type_error_msg + if shape[1] not in (2, 3): raise ValueError( f"Each coordinate must be of size 2 or 3, not {shape[1]}." ) + object.__setattr__(self, "_trap_coordinates", trap_coordinates) + object.__setattr__(self, "slug", slug) @property def traps_dict(self) -> dict: @@ -79,7 +95,7 @@ def traps_dict(self) -> dict: @cached_property # Acts as an attribute in a frozen dataclass def _coords(self) -> np.ndarray: - coords = np.array(self.trap_coordinates, dtype=float) + coords = np.array(self._trap_coordinates, dtype=float) # Sorting the coordinates 1st left to right, 2nd bottom to top rounded_coords = np.round(coords, decimals=COORD_PRECISION) dims = rounded_coords.shape[1] @@ -105,7 +121,15 @@ def number_of_traps(self) -> int: @property def max_atom_num(self) -> int: """Maximum number of atoms that can be trapped to form a Register.""" - return self.number_of_traps // 2 + warn( + "'RegisterLayout.max_atom_num' is deprecated and will be removed" + " in version 0.9.0.\n" + "It is now the same as 'RegisterLayout.number_of_traps' and " + "should be replaced accordingly.", + DeprecationWarning, + stacklevel=2, + ) + return self.number_of_traps @property def dimensionality(self) -> int: @@ -155,6 +179,7 @@ def define_register( raise ValueError("Every 'trap_id' must be a unique integer.") if not trap_ids_set.issubset(self.traps_dict): + # This check makes it redundant to check # qubits <= # traps raise ValueError( "All 'trap_ids' must correspond to the ID of a trap." ) @@ -170,12 +195,6 @@ def define_register( f"provided 'trap_ids' ({len(trap_ids)})." ) - if len(trap_ids) > self.max_atom_num: - raise ValueError( - "The number of required traps is greater than the maximum " - "number of qubits allowed for this layout " - f"({self.max_atom_num})." - ) ids = ( qubit_ids if qubit_ids else [f"q{i}" for i in range(len(trap_ids))] ) @@ -293,15 +312,20 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f"RegisterLayout_{self._safe_hash().hex()}" + def __str__(self) -> str: + return self.slug or self.__repr__() + def _to_dict(self) -> dict[str, Any]: # Allows for serialization of subclasses without a special _to_dict() return obj_to_dict( self, - self.trap_coordinates, + self._trap_coordinates, _module=__name__, _name="RegisterLayout", ) def _to_abstract_repr(self) -> dict[str, list[list[float]]]: - # TODO: Include the layout slug once that's added - return {"coordinates": self.coords.tolist()} + d = {"coordinates": self.coords.tolist()} + if self.slug is not None: + d["slug"] = self.slug + return d diff --git a/pulser-core/pulser/register/special_layouts.py b/pulser-core/pulser/register/special_layouts.py index 1f56b56bc..4fff347c9 100644 --- a/pulser-core/pulser/register/special_layouts.py +++ b/pulser-core/pulser/register/special_layouts.py @@ -37,8 +37,13 @@ def __init__(self, rows: int, columns: int, spacing: int): self._rows = int(rows) self._columns = int(columns) self._spacing = int(spacing) + slug = ( + f"SquareLatticeLayout({self._rows}x{self._columns}, " + f"{self._spacing}µm)" + ) super().__init__( - patterns.square_rect(self._rows, self._columns) * self._spacing + patterns.square_rect(self._rows, self._columns) * self._spacing, + slug=slug, ) def square_register(self, side: int, prefix: str = "q") -> Register: @@ -73,14 +78,9 @@ def rectangular_register( Returns: The register instance created from this layout. """ - if rows * columns > self.max_atom_num: - raise ValueError( - f"A '{rows} x {columns}' array has more atoms than those " - f"available in this SquareLatticeLayout ({self.max_atom_num})." - ) if rows > self._rows or columns > self._columns: raise ValueError( - f"A '{rows} x {columns}' array doesn't fit a " + f"A '{rows}x{columns}' array doesn't fit a " f"{self._rows}x{self._columns} SquareLatticeLayout." ) points = patterns.square_rect(rows, columns) * self._spacing @@ -90,12 +90,6 @@ def rectangular_register( Register, self.define_register(*trap_ids, qubit_ids=qubit_ids) ) - def __str__(self) -> str: - return ( - f"SquareLatticeLayout({self._rows}x{self._columns}, " - f"{self._spacing}µm)" - ) - def _to_dict(self) -> dict[str, Any]: return obj_to_dict(self, self._rows, self._columns, self._spacing) @@ -111,7 +105,10 @@ class TriangularLatticeLayout(RegisterLayout): def __init__(self, n_traps: int, spacing: int): """Initializes a TriangularLatticeLayout.""" self._spacing = int(spacing) - super().__init__(patterns.triangular_hex(int(n_traps)) * self._spacing) + slug = f"TriangularLatticeLayout({int(n_traps)}, {self._spacing}µm)" + super().__init__( + patterns.triangular_hex(int(n_traps)) * self._spacing, slug=slug + ) def hexagonal_register(self, n_atoms: int, prefix: str = "q") -> Register: """Defines a register with an hexagonal shape. @@ -125,10 +122,11 @@ def hexagonal_register(self, n_atoms: int, prefix: str = "q") -> Register: Returns: The register instance created from this layout. """ - if n_atoms > self.max_atom_num: + if n_atoms > self.number_of_traps: raise ValueError( - f"This RegisterLayout can hold at most {self.max_atom_num} " - f"atoms, not '{n_atoms}'." + f"The desired register has more atoms ({n_atoms}) than there" + " are traps in this TriangularLatticeLayout" + f" ({self.number_of_traps})." ) points = patterns.triangular_hex(n_atoms) * self._spacing trap_ids = self.get_traps_from_coordinates(*points) @@ -152,11 +150,11 @@ def rectangular_register( Returns: The register instance created from this layout. """ - if rows * atoms_per_row > self.max_atom_num: + if rows * atoms_per_row > self.number_of_traps: raise ValueError( - f"A '{rows} x {atoms_per_row}' rectangular subset of a " - "triangular lattice has more atoms than those available in " - f"this TriangularLatticeLayout ({self.max_atom_num})." + f"A '{rows}x{atoms_per_row}' rectangular subset of a " + "triangular lattice has more atoms than there are traps in " + f"this TriangularLatticeLayout ({self.number_of_traps})." ) points = patterns.triangular_rect(rows, atoms_per_row) * self._spacing trap_ids = self.get_traps_from_coordinates(*points) @@ -165,11 +163,5 @@ def rectangular_register( Register, self.define_register(*trap_ids, qubit_ids=qubit_ids) ) - def __str__(self) -> str: - return ( - f"TriangularLatticeLayout({self.number_of_traps}, " - f"{self._spacing}µm)" - ) - def _to_dict(self) -> dict[str, Any]: return obj_to_dict(self, self.number_of_traps, self._spacing) diff --git a/pulser-core/pulser/sampler/sampler.py b/pulser-core/pulser/sampler/sampler.py index bedb5d231..65d795c80 100644 --- a/pulser-core/pulser/sampler/sampler.py +++ b/pulser-core/pulser/sampler/sampler.py @@ -5,7 +5,7 @@ from pulser.sampler.samples import SequenceSamples, _SlmMask -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser import Sequence @@ -22,14 +22,22 @@ def sample( extended_duration: If defined, extends the samples duration to the desired value. """ - samples_list = [ - ch_schedule.get_samples(modulated=modulation) - for ch_schedule in seq._schedule.values() - ] - if extended_duration: - samples_list = [ - cs.extend_duration(extended_duration) for cs in samples_list - ] + if seq.is_parametrized(): + raise NotImplementedError("Parametrized sequences can't be sampled.") + + samples_list = [] + for ch_schedule in seq._schedule.values(): + samples = ch_schedule.get_samples() + if extended_duration: + samples = samples.extend_duration(extended_duration) + if modulation: + samples = samples.modulate( + ch_schedule.channel_obj, + max_duration=extended_duration + or ch_schedule.get_duration(include_fall_time=True), + ) + samples_list.append(samples) + optionals = {} if seq._slm_mask_targets and seq._slm_mask_time: optionals["_slm_mask"] = _SlmMask( diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 4459a0488..4e21ac0bb 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -2,12 +2,12 @@ from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Optional import numpy as np -from pulser.channels import Channel +from pulser.channels.base_channel import Channel from pulser.register import QubitId """Literal constants for addressing.""" @@ -86,6 +86,8 @@ class ChannelSamples: det: np.ndarray phase: np.ndarray slots: list[_TargetSlot] = field(default_factory=list) + # (t_start, t_end) of each EOM mode block + eom_intervals: list[tuple[int, int]] = field(default_factory=list) def __post_init__(self) -> None: assert len(self.amp) == len(self.det) == len(self.phase) @@ -120,7 +122,7 @@ def extend_duration(self, new_duration: int) -> ChannelSamples: (0, extension), mode="edge" if self.phase.size > 0 else "constant", ) - return ChannelSamples(new_amp, new_detuning, new_phase, self.slots) + return replace(self, amp=new_amp, det=new_detuning, phase=new_phase) def is_empty(self) -> bool: """Whether the channel is effectively empty. @@ -148,11 +150,43 @@ def modulate( Returns: The modulated channel samples. """ - times = slice(0, max_duration) - new_amp = channel_obj.modulate(self.amp)[times] - new_detuning = channel_obj.modulate(self.det)[times] - new_phase = channel_obj.modulate(self.phase, keep_ends=True)[times] - return ChannelSamples(new_amp, new_detuning, new_phase, self.slots) + + def masked(samples: np.ndarray, mask: np.ndarray) -> np.ndarray: + new_samples = samples.copy() + new_samples[~mask] = 0 + return new_samples + + new_samples: dict[str, np.ndarray] = {} + + if self.eom_intervals: + eom_mask = np.zeros(self.duration, dtype=bool) + for start, end in self.eom_intervals: + end = min(end, self.duration) # This is defensive + eom_mask[np.arange(start, end)] = True + + for key in ("amp", "det"): + samples = getattr(self, key) + std = channel_obj.modulate(masked(samples, ~eom_mask)) + eom = channel_obj.modulate(masked(samples, eom_mask), eom=True) + sample_arrs = [std, eom] + sample_arrs.sort(key=len) + # Extend shortest array to match the longest + sample_arrs[0] = np.concatenate( + ( + sample_arrs[0], + np.zeros(sample_arrs[1].size - sample_arrs[0].size), + ) + ) + new_samples[key] = sample_arrs[0] + sample_arrs[1] + + else: + new_samples["amp"] = channel_obj.modulate(self.amp) + new_samples["det"] = channel_obj.modulate(self.det) + + new_samples["phase"] = channel_obj.modulate(self.phase, keep_ends=True) + for key in new_samples: + new_samples[key] = new_samples[key][slice(0, max_duration)] + return replace(self, **new_samples) @dataclass diff --git a/pulser-core/pulser/sequence/_decorators.py b/pulser-core/pulser/sequence/_decorators.py index cce2da1e8..36e220bef 100644 --- a/pulser-core/pulser/sequence/_decorators.py +++ b/pulser-core/pulser/sequence/_decorators.py @@ -22,7 +22,7 @@ from pulser.parametrized import Parametrized from pulser.sequence._call import _Call -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.sequence.sequence import Sequence F = TypeVar("F", bound=Callable) diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index ab1760ae0..b9ae04f5f 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -21,10 +21,11 @@ import numpy as np -from pulser.channels import Channel +from pulser.channels.base_channel import Channel from pulser.pulse import Pulse from pulser.register.base_register import QubitId from pulser.sampler.samples import ChannelSamples, _TargetSlot +from pulser.waveforms import ConstantWaveform class _TimeSlot(NamedTuple): @@ -36,6 +37,15 @@ class _TimeSlot(NamedTuple): targets: set[QubitId] +@dataclass +class _EOMSettings: + rabi_freq: float + detuning_on: float + detuning_off: float + ti: int + tf: Optional[int] = None + + @dataclass class _ChannelSchedule: channel_id: str @@ -43,6 +53,7 @@ class _ChannelSchedule: def __post_init__(self) -> None: self.slots: list[_TimeSlot] = [] + self.eom_blocks: list[_EOMSettings] = [] def last_target(self) -> int: """Last time a target happened on the channel.""" @@ -51,6 +62,40 @@ def last_target(self) -> int: return slot.tf return 0 # pragma: no cover + def last_pulse_slot(self) -> _TimeSlot: + """The last slot with a Pulse.""" + for slot in self.slots[::-1]: + if isinstance(slot.type, Pulse) and not self.is_eom_delay(slot): + return slot + raise RuntimeError("There is no slot with a pulse.") + + def in_eom_mode(self, time_slot: Optional[_TimeSlot] = None) -> bool: + """States if a time slot is inside an EOM mode block.""" + if time_slot is None: + return bool(self.eom_blocks) and (self.eom_blocks[-1].tf is None) + return any( + start <= time_slot.ti < end + for start, end in self.get_eom_mode_intervals() + ) + + def is_eom_delay(self, slot: _TimeSlot) -> bool: + """Tells if a pulse slot is actually an EOM delay.""" + return ( + self.in_eom_mode(time_slot=slot) + and isinstance(slot.type, Pulse) + and isinstance(slot.type.amplitude, ConstantWaveform) + and slot.type.amplitude[0] == 0.0 + ) + + def get_eom_mode_intervals(self) -> list[tuple[int, int]]: + return [ + ( + block.ti, + block.tf if block.tf is not None else self.get_duration(), + ) + for block in self.eom_blocks + ] + def get_duration(self, include_fall_time: bool = False) -> int: temp_tf = 0 for i, op in enumerate(self.slots[::-1]): @@ -61,7 +106,11 @@ def get_duration(self, include_fall_time: bool = False) -> int: break if isinstance(op.type, Pulse): temp_tf = max( - temp_tf, op.tf + op.type.fall_time(self.channel_obj) + temp_tf, + op.tf + + op.type.fall_time( + self.channel_obj, in_eom_mode=self.in_eom_mode() + ), ) break elif temp_tf - op.tf >= 2 * self.channel_obj.rise_time: @@ -77,12 +126,8 @@ def adjust_duration(self, duration: int) -> int: max(duration, self.channel_obj.min_duration) ) - def get_samples(self, modulated: bool = False) -> ChannelSamples: - """Returns the samples of the channel. - - Args: - modulated: Whether to return the modulated samples. - """ + def get_samples(self) -> ChannelSamples: + """Returns the samples of the channel.""" # Keep only pulse slots channel_slots = [s for s in self.slots if isinstance(s.type, Pulse)] dt = self.get_duration() @@ -102,25 +147,22 @@ def get_samples(self, modulated: bool = False) -> ChannelSamples: ) phase[t_start:t_end] += pulse.phase tf = s.tf - if modulated: - # Account for the extended duration of the pulses - # after modulation, which is at most fall_time - fall_time = pulse.fall_time(self.channel_obj) - tf += ( - min(fall_time, channel_slots[ind + 1].ti - s.tf) - if ind < len(channel_slots) - 1 - else fall_time - ) + # Account for the extended duration of the pulses + # after modulation, which is at most fall_time + fall_time = pulse.fall_time( + self.channel_obj, in_eom_mode=self.in_eom_mode(time_slot=s) + ) + tf += ( + min(fall_time, channel_slots[ind + 1].ti - s.tf) + if ind < len(channel_slots) - 1 + else fall_time + ) slots.append(_TargetSlot(s.ti, tf, s.targets)) - ch_samples = ChannelSamples(amp, det, phase, slots) - - if modulated: - ch_samples = ch_samples.modulate( - self.channel_obj, - max_duration=self.get_duration(include_fall_time=True), - ) + ch_samples = ChannelSamples( + amp, det, phase, slots, self.get_eom_mode_intervals() + ) return ch_samples @@ -177,6 +219,36 @@ def find_slm_mask_times(self) -> list[int]: break return mask_time + def enable_eom( + self, + channel_id: str, + amp_on: float, + detuning_on: float, + detuning_off: float, + ) -> None: + channel_obj = self[channel_id].channel_obj + if any(isinstance(op.type, Pulse) for op in self[channel_id]): + # Wait for the last pulse to ramp down (if needed) + self.wait_for_fall(channel_id) + # Account for time needed to ramp to desired amplitude + # By definition, rise_time goes from 10% to 90% + # Roughly 2*rise_time is enough to go from 0% to 100% + self.add_delay(2 * channel_obj.rise_time, channel_id) + + # Set up the EOM + eom_settings = _EOMSettings( + rabi_freq=amp_on, + detuning_on=detuning_on, + detuning_off=detuning_off, + ti=self[channel_id][-1].tf, + ) + + self[channel_id].eom_blocks.append(eom_settings) + + def disable_eom(self, channel_id: str) -> None: + self[channel_id].eom_blocks[-1].tf = self[channel_id][-1].tf + self.wait_for_fall(channel_id) + def add_pulse( self, pulse: Pulse, @@ -188,27 +260,32 @@ def add_pulse( last = self[channel][-1] t0 = last.tf current_max_t = max(t0, *phase_barrier_ts) + # Buffer to add between pulses of different phase phase_jump_buffer = 0 - for ch, ch_schedule in self.items(): - if protocol == "no-delay" and ch != channel: - continue - this_chobj = self[ch].channel_obj - for op in ch_schedule[::-1]: - if not isinstance(op.type, Pulse): - if op.tf + 2 * this_chobj.rise_time <= current_max_t: - # No pulse behind 'op' needing a delay - break - elif ch == channel: - if op.type.phase != pulse.phase: - phase_jump_buffer = this_chobj.phase_jump_time - ( - t0 - op.tf + if protocol != "no-delay": + current_max_t = self._find_add_delay( + current_max_t, channel, protocol + ) + try: + # Gets the last pulse on the channel + last_pulse_slot = self[channel].last_pulse_slot() + last_pulse = cast(Pulse, last_pulse_slot.type) + # Checks if the current pulse changes the phase + if last_pulse.phase != pulse.phase: + # Subtracts the time that has already elapsed since the + # last pulse from the phase_jump_time and adds the + # fall_time to let the last pulse ramp down + ch_obj = self[channel].channel_obj + phase_jump_buffer = ( + ch_obj.phase_jump_time + + last_pulse.fall_time( + ch_obj, in_eom_mode=self[channel].in_eom_mode() ) - break - elif op.tf + op.type.fall_time(this_chobj) <= current_max_t: - break - elif op.targets & last.targets or protocol == "wait-for-all": - current_max_t = op.tf + op.type.fall_time(this_chobj) - break + - (t0 - last_pulse_slot.tf) + ) + except RuntimeError: + # No previous pulse + pass delay_duration = max(current_max_t - t0, phase_jump_buffer) if delay_duration > 0: @@ -223,19 +300,30 @@ def add_delay(self, duration: int, channel: str) -> None: last = self[channel][-1] ti = last.tf tf = ti + self[channel].channel_obj.validate_duration(duration) - self[channel].slots.append(_TimeSlot("delay", ti, tf, last.targets)) + if ( + self[channel].in_eom_mode() + and self[channel].eom_blocks[-1].detuning_off != 0 + ): + try: + last_pulse = cast(Pulse, self[channel].last_pulse_slot().type) + phase = last_pulse.phase + except RuntimeError: + phase = 0.0 + delay_pulse = Pulse.ConstantPulse( + tf - ti, 0.0, self[channel].eom_blocks[-1].detuning_off, phase + ) + self[channel].slots.append( + _TimeSlot(delay_pulse, ti, tf, last.targets) + ) + else: + self[channel].slots.append( + _TimeSlot("delay", ti, tf, last.targets) + ) def add_target(self, qubits_set: set[QubitId], channel: str) -> None: channel_obj = self[channel].channel_obj if self[channel].slots: - fall_time = ( - self[channel].get_duration(include_fall_time=True) - - self[channel].get_duration() - ) - if fall_time > 0: - self.add_delay( - self[channel].adjust_duration(fall_time), channel - ) + self.wait_for_fall(channel) last = self[channel][-1] if last.targets == qubits_set: @@ -257,3 +345,43 @@ def add_target(self, qubits_set: set[QubitId], channel: str) -> None: self[channel].slots.append( _TimeSlot("target", ti, tf, set(qubits_set)) ) + + def wait_for_fall(self, channel: str) -> None: + """Adds a delay to let the channel's amplitude ramp down.""" + # Extra time needed for the output to finish + fall_time = ( + self[channel].get_duration(include_fall_time=True) + - self[channel].get_duration() + ) + # If there is a fall time, a delay is added to account for it + if fall_time > 0: + self.add_delay(self[channel].adjust_duration(fall_time), channel) + + def _find_add_delay(self, t0: int, channel: str, protocol: str) -> int: + current_max_t = t0 + for ch, ch_schedule in self.items(): + if ch == channel: + continue + this_chobj = self[ch].channel_obj + in_eom_mode = self[ch].in_eom_mode() + for op in ch_schedule[::-1]: + if not isinstance(op.type, Pulse): + if op.tf + 2 * this_chobj.rise_time <= current_max_t: + # No pulse behind 'op' needing a delay + break + elif ( + op.tf + + op.type.fall_time(this_chobj, in_eom_mode=in_eom_mode) + <= current_max_t + ): + break + elif ( + op.targets & self[channel][-1].targets + or protocol == "wait-for-all" + ): + current_max_t = op.tf + op.type.fall_time( + this_chobj, in_eom_mode=in_eom_mode + ) + break + + return current_max_t diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index 6080e70f1..2618969b8 100644 --- a/pulser-core/pulser/sequence/_seq_drawer.py +++ b/pulser-core/pulser/sequence/_seq_drawer.py @@ -26,7 +26,7 @@ import pulser from pulser import Register, Register3D -from pulser.channels import Channel +from pulser.channels.base_channel import Channel from pulser.pulse import Pulse from pulser.sampler.samples import ChannelSamples from pulser.waveforms import InterpolatedWaveform @@ -327,11 +327,10 @@ def phase_str(phi: float) -> str: max_amp = 1 if max_amp == 0 else max_amp amp_top = max_amp * 1.2 amp_bottom = min(0.0, *ref_ys[0]) - det_max = np.max(ref_ys[1]) - det_min = np.min(ref_ys[1]) + # Makes sure that [-1, 1] range is always represented + det_max = max(*ref_ys[1], 1) + det_min = min(*ref_ys[1], -1) det_range = det_max - det_min - if det_range == 0: - det_min, det_max, det_range = -1, 1, 2 det_top = det_max + det_range * 0.15 det_bottom = det_min - det_range * 0.05 ax_lims = [ diff --git a/pulser-core/pulser/sequence/_seq_str.py b/pulser-core/pulser/sequence/_seq_str.py index a53e36080..837ab59a8 100644 --- a/pulser-core/pulser/sequence/_seq_str.py +++ b/pulser-core/pulser/sequence/_seq_str.py @@ -18,7 +18,7 @@ from pulser.pulse import Pulse -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from pulser.sequence.sequence import Sequence @@ -28,7 +28,7 @@ def seq_to_str(sequence: Sequence) -> str: pulse_line = "t: {}->{} | {} | Targets: {}\n" target_line = "t: {}->{} | Target: {} | Phase Reference: {}\n" delay_line = "t: {}->{} | Delay \n" - # phase_line = "t: {} | Phase shift of: {:.3f} | Targets: {}\n" + eom_delay_line = "t: {}->{} | EOM Delay | Detuning: {:.3g} rad/µs\n" for ch, seq in sequence._schedule.items(): basis = sequence.declared_channels[ch].basis full += f"Channel: {ch}\n" @@ -38,10 +38,19 @@ def seq_to_str(sequence: Sequence) -> str: full += delay_line.format(ts.ti, ts.tf) continue - tgts = list(ts.targets) - tgt_txt = ", ".join([str(t) for t in tgts]) + try: + tgts = sorted(ts.targets) + except TypeError: + raise NotImplementedError( + "Can't print sequence with qubit IDs of different types." + ) + tgt_txt = ", ".join(map(str, tgts)) if isinstance(ts.type, Pulse): - full += pulse_line.format(ts.ti, ts.tf, ts.type, tgt_txt) + if seq.is_eom_delay(ts): + det = ts.type.detuning[0] + full += eom_delay_line.format(ts.ti, ts.tf, det) + else: + full += pulse_line.format(ts.ti, ts.tf, ts.type, tgt_txt) elif ts.type == "target": phase = sequence._basis_ref[basis][tgts[0]].phase[ts.tf] if first_slot: diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index 45db9c362..05d25447f 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -29,9 +29,9 @@ import pulser import pulser.sequence._decorators as seq_decorators -from pulser.channels import Channel -from pulser.devices import MockDevice -from pulser.devices._device_datacls import Device +from pulser.channels.base_channel import Channel +from pulser.channels.eom import RydbergEOM +from pulser.devices._device_datacls import BaseDevice from pulser.json.abstract_repr.deserializer import ( deserialize_abstract_sequence, ) @@ -101,35 +101,25 @@ class Sequence: """ def __init__( - self, register: Union[BaseRegister, MappableRegister], device: Device + self, + register: Union[BaseRegister, MappableRegister], + device: BaseDevice, ): """Initializes a new pulse sequence.""" - if not isinstance(device, Device): + if not isinstance(device, BaseDevice): raise TypeError( - "'device' must be of type 'Device'. Import a valid" - " device from 'pulser.devices'." - ) - cond1 = device not in pulser.devices._valid_devices - cond2 = device not in pulser.devices._mock_devices - if cond1 and cond2: - names = [d.name for d in pulser.devices._valid_devices] - warns_msg = ( - "The Sequence's device should be imported from " - + "'pulser.devices'. Correct operation is not ensured" - + " for custom devices. Choose 'MockDevice'" - + " or one of the following real devices:\n" - + "\n".join(names) + f"'device' must be of type 'BaseDevice', not {type(device)}." ) - warnings.warn(warns_msg, stacklevel=2) # Checks if register is compatible with the device if isinstance(register, MappableRegister): device.validate_layout(register.layout) + device.validate_layout_filling(register) else: device.validate_register(register) self._register: Union[BaseRegister, MappableRegister] = register - self._device: Device = device + self._device: BaseDevice = device self._in_xy: bool = False self._mag_field: Optional[tuple[float, float, float]] = None self._calls: list[_Call] = [_Call("__init__", (register, device), {})] @@ -168,6 +158,11 @@ def qubit_info(self) -> dict[QubitId, np.ndarray]: ) return cast(BaseRegister, self._register).qubits + @property + def device(self) -> BaseDevice: + """Device that the sequence is using.""" + return self._device + @property def register(self) -> BaseRegister: """Register with the qubit's IDs and positions.""" @@ -202,7 +197,9 @@ def available_channels(self) -> dict[str, Channel]: return { id: ch for id, ch in self._device.channels.items() - if (id not in occupied_ch_ids or self._device == MockDevice) + if ( + id not in occupied_ch_ids or self._device.reusable_channels + ) and (ch.basis == "XY" if self._in_xy else ch.basis != "XY") } @@ -236,6 +233,32 @@ def is_parametrized(self) -> bool: """ return not self._building + def is_in_eom_mode(self, channel: str) -> bool: + """States whether a channel is currently in EOM mode. + + Args: + channel: The name of the declared channel to inspect. + + Returns: + Whether the channel is in EOM mode. + + """ + self._validate_channel(channel) + if not self.is_parametrized(): + return self._schedule[channel].in_eom_mode() + + # Look for the latest stored EOM mode enable/disable + for call in reversed(self._calls + self._to_build_calls): + if call.name not in ("enable_eom_mode", "disable_eom_mode"): + continue + # Channel is the first positional arg in both methods + ch_arg = call.args[0] if call.args else call.kwargs["channel"] + if ch_arg == channel: + # If it's not "enable_eom_mode", then it's "disable_eom_mode" + return cast(bool, call.name == "enable_eom_mode") + # If it reaches here, there were no EOM calls found + return False + def is_register_mappable(self) -> bool: """States whether the sequence's register is mappable. @@ -353,6 +376,10 @@ def config_slm_mask(self, qubits: Iterable[QubitId]) -> None: qubits: Iterable of qubit ID's to mask during the first global pulse of the sequence. """ + if not self._device.supports_slm_mask: + raise ValueError( + f"The '{self._device}' device does not have an SLM mask." + ) try: targets = set(qubits) except TypeError: @@ -370,6 +397,135 @@ def config_slm_mask(self, qubits: Iterable[QubitId]) -> None: # If checks have passed, set the SLM mask targets self._slm_mask_targets = targets + @seq_decorators.screen + def switch_device( + self, new_device: BaseDevice, strict: bool = False + ) -> Sequence: + """Switch the device of a sequence. + + Args: + new_device: The target device instance. + strict: Enforce a strict match between devices and channels to + guarantee the pulse sequence is left unchanged. + + Returns: + The sequence on the new device, using the match channels of + the former device declared in the sequence. + """ + # Check if the device is new or not + + if self._device == new_device: + warnings.warn( + "Switching a sequence to the same device" + + " returns the sequence unchanged.", + stacklevel=2, + ) + return self + + if new_device.rydberg_level != self._device.rydberg_level: + if strict: + raise ValueError( + "Strict device match failed because the" + + " devices have different Rydberg levels." + ) + warnings.warn( + "Switching to a device with a different Rydberg level," + " check that the expected Rydberg interactions still hold.", + stacklevel=2, + ) + + # Channel match + channel_match: dict[str, Any] = {} + strict_error_message = "" + ch_type_er_mess = "" + for o_d_ch_name, o_d_ch_obj in self.declared_channels.items(): + channel_match[o_d_ch_name] = None + for n_d_ch_id, n_d_ch_obj in new_device.channels.items(): + if ( + not new_device.reusable_channels + and n_d_ch_id in channel_match.values() + ): + # Channel already matched and can't be reused + continue + # Find the corresponding channel on the new device + # We verify the channel class then + # check whether the addressing Global or local + basis_match = o_d_ch_obj.basis == n_d_ch_obj.basis + addressing_match = ( + o_d_ch_obj.addressing == n_d_ch_obj.addressing + ) + base_msg = f"No match for channel {o_d_ch_name}" + if not (basis_match and addressing_match): + # If there already is a message, keeps it + ch_type_er_mess = ch_type_er_mess or ( + base_msg + " with the right basis and addressing." + ) + continue + if self._schedule[o_d_ch_name].eom_blocks: + if n_d_ch_obj.eom_config is None: + ch_type_er_mess = ( + base_msg + " with an EOM configuration." + ) + continue + if ( + n_d_ch_obj.eom_config != o_d_ch_obj.eom_config + and strict + ): + strict_error_message = ( + base_msg + " with the same EOM configuration." + ) + continue + if not strict: + channel_match[o_d_ch_name] = n_d_ch_id + break + if n_d_ch_obj.mod_bandwidth != o_d_ch_obj.mod_bandwidth: + strict_error_message = strict_error_message or ( + base_msg + " with the same mod_bandwidth." + ) + continue + if n_d_ch_obj.fixed_retarget_t != o_d_ch_obj.fixed_retarget_t: + strict_error_message = strict_error_message or ( + base_msg + " with the same fixed_retarget_t." + ) + continue + + # Clock_period check + if o_d_ch_obj.clock_period == n_d_ch_obj.clock_period: + channel_match[o_d_ch_name] = n_d_ch_id + break + strict_error_message = strict_error_message or ( + base_msg + " with the same clock_period." + ) + + if None in channel_match.values(): + if strict_error_message: + raise ValueError(strict_error_message) + else: + raise TypeError(ch_type_er_mess) + # Initialize the new sequence + new_seq = Sequence(self.register, new_device) + + for call in self._calls[1:]: + if not (call.name == "declare_channel"): + getattr(new_seq, call.name)(*call.args, **call.kwargs) + continue + # Switch the old id with the correct id + sw_channel_args = list(call.args) + sw_channel_kw_args = call.kwargs.copy() + if "name" in sw_channel_kw_args: # pragma: no cover + sw_channel_kw_args["channel_id"] = channel_match[ + sw_channel_kw_args["name"] + ] + elif "channel_id" in sw_channel_kw_args: # pragma: no cover + sw_channel_kw_args["channel_id"] = channel_match[ + sw_channel_args[0] + ] + else: + sw_channel_args[1] = channel_match[sw_channel_args[0]] + + new_seq.declare_channel(*sw_channel_args, **sw_channel_kw_args) + return new_seq + @seq_decorators.block_if_measured def declare_channel( self, @@ -533,6 +689,174 @@ def declare_variable( self._variables[name] = var return var + @seq_decorators.store + @seq_decorators.block_if_measured + def enable_eom_mode( + self, + channel: str, + amp_on: Union[float, Parametrized], + detuning_on: Union[float, Parametrized], + optimal_detuning_off: Union[float, Parametrized] = 0.0, + ) -> None: + """Puts a channel in EOM mode operation. + + For channels with a finite modulation bandwidth and an EOM, operation + in EOM mode allows for the execution of square pulses with a higher + bandwidth than that which is tipically available. It can be turned on + and off through the `Sequence.enable_eom_mode()` and + `Sequence.disable_eom_mode()` methods. + A channel in EOM mode can only execute square pulses with a given + amplitude (`amp_on`) and detuning (`detuning_on`), which are + chosen at the moment the EOM mode is enabled. Furthermore, the + detuning when there is no pulse being played (`detuning_off`) is + restricted to a set of values that depends on `amp_on` and + `detuning_on`. + While in EOM mode, one can only add pulses of variable duration + (through `Sequence.add_eom_pulse()`) or delays. + + Note: + Enabling the EOM mode will automatically enforce a buffer time from + the last pulse on the chose channel. + + Args: + channel: The name of the channel to put in EOM mode. + amp_on: The amplitude of the EOM pulses (in rad/µs). + detuning_on: The detuning of the EOM pulses (in rad/µs). + optimal_detuning_off: The optimal value of detuning (in rad/µs) + when there is no pulse being played. It will choose the closest + value among the existing options. + """ + if self.is_in_eom_mode(channel): + raise RuntimeError( + f"The '{channel}' channel is already in EOM mode." + ) + channel_obj = self.declared_channels[channel] + if not channel_obj.supports_eom(): + raise TypeError(f"Channel '{channel}' does not have an EOM.") + + on_pulse = Pulse.ConstantPulse( + channel_obj.min_duration, amp_on, detuning_on, 0.0 + ) + if not isinstance(on_pulse, Parametrized): + channel_obj.validate_pulse(on_pulse) + amp_on = cast(float, amp_on) + detuning_on = cast(float, detuning_on) + + off_options = cast( + RydbergEOM, channel_obj.eom_config + ).detuning_off_options(amp_on, detuning_on) + + if not isinstance(optimal_detuning_off, Parametrized): + closest_option = np.abs( + off_options - optimal_detuning_off + ).argmin() + detuning_off = off_options[closest_option] + off_pulse = Pulse.ConstantPulse( + channel_obj.min_duration, 0.0, detuning_off, 0.0 + ) + channel_obj.validate_pulse(off_pulse) + + if not self.is_parametrized(): + self._schedule.enable_eom( + channel, amp_on, detuning_on, detuning_off + ) + + @seq_decorators.store + @seq_decorators.block_if_measured + def disable_eom_mode(self, channel: str) -> None: + """Takes a channel out of EOM mode operation. + + For channels with a finite modulation bandwidth and an EOM, operation + in EOM mode allows for the execution of square pulses with a higher + bandwidth than that which is tipically available. It can be turned on + and off through the `Sequence.enable_eom_mode()` and + `Sequence.disable_eom_mode()` methods. + A channel in EOM mode can only execute square pulses with a given + amplitude (`amp_on`) and detuning (`detuning_on`), which are + chosen at the moment the EOM mode is enabled. Furthermore, the + detuning when there is no pulse being played (`detuning_off`) is + restricted to a set of values that depends on `amp_on` and + `detuning_on`. + While in EOM mode, one can only add pulses of variable duration + (through `Sequence.add_eom_pulse()`) or delays. + + Note: + Disable the EOM mode will automatically enforce a buffer time from + the moment it is turned off. + + Args: + channel: The name of the channel to take out of EOM mode. + """ + if not self.is_in_eom_mode(channel): + raise RuntimeError(f"The '{channel}' channel is not in EOM mode.") + if not self.is_parametrized(): + self._schedule.disable_eom(channel) + + @seq_decorators.store + @seq_decorators.mark_non_empty + @seq_decorators.block_if_measured + def add_eom_pulse( + self, + channel: str, + duration: Union[int, Parametrized], + phase: Union[float, Parametrized], + post_phase_shift: Union[float, Parametrized] = 0.0, + protocol: PROTOCOLS = "min-delay", + ) -> None: + """Adds a square pulse to a channel in EOM mode. + + For channels with a finite modulation bandwidth and an EOM, operation + in EOM mode allows for the execution of square pulses with a higher + bandwidth than that which is tipically available. It can be turned on + and off through the `Sequence.enable_eom_mode()` and + `Sequence.disable_eom_mode()` methods. + A channel in EOM mode can only execute square pulses with a given + amplitude (`amp_on`) and detuning (`detuning_on`), which are + chosen at the moment the EOM mode is enabled. Furthermore, the + detuning when there is no pulse being played (`detuning_off`) is + restricted to a set of values that depends on `amp_on` and + `detuning_on`. + While in EOM mode, one can only add pulses of variable duration + (through `Sequence.add_eom_pulse()`) or delays. + + Note: + When the phase between pulses is changed, the necessary buffer + time for a phase jump will still be enforced (unless + ``protocol='no-delay'``). + + Args: + channel: The name of the channel to add the pulse to. + duration: The duration of the pulse (in ns). + phase: The pulse phase (in radians). + post_phase_shift: Optionally lets you add a phase shift (in rads) + immediately after the end of the pulse. + protocol: Stipulates how to deal with eventual conflicts with + other channels (see `Sequence.add()` for more details). + """ + if not self.is_in_eom_mode(channel): + raise RuntimeError(f"Channel '{channel}' must be in EOM mode.") + + if self.is_parametrized(): + self._validate_add_protocol(protocol) + # Test the parameters + if not isinstance(duration, Parametrized): + channel_obj = self.declared_channels[channel] + channel_obj.validate_duration(duration) + for arg in (phase, post_phase_shift): + if not isinstance(arg, (float, int)): + raise TypeError("Phase values must be a numeric value.") + return + + eom_settings = self._schedule[channel].eom_blocks[-1] + eom_pulse = Pulse.ConstantPulse( + duration, + eom_settings.rabi_freq, + eom_settings.detuning_on, + phase, + post_phase_shift=post_phase_shift, + ) + self._add(eom_pulse, channel, protocol) + @seq_decorators.store @seq_decorators.mark_non_empty @seq_decorators.block_if_measured @@ -553,61 +877,24 @@ def add( simultaneously. - ``'min-delay'``: Before adding the pulse, introduces the - smallest possible delay that avoids all exisiting conflicts. + smallest possible delay that avoids all exisiting conflicts. - ``'no-delay'``: Adds the pulse to the channel, regardless of - existing conflicts. + existing conflicts. - ``'wait-for-all'``: Before adding the pulse, adds a delay - that idles the channel until the end of the other channels' - latest pulse. - """ - self._validate_channel(channel) - - valid_protocols = get_args(PROTOCOLS) - if protocol not in valid_protocols: - raise ValueError( - f"Invalid protocol '{protocol}', only accepts protocols: " - + ", ".join(valid_protocols) - ) - - if self.is_parametrized(): - if not isinstance(pulse, Parametrized): - self._validate_and_adjust_pulse(pulse, channel) - return - - pulse = cast(Pulse, pulse) - channel_obj = self._schedule[channel].channel_obj - last = self._last(channel) - basis = channel_obj.basis + that idles the channel until the end of the other channels' + latest pulse. - ph_refs = { - self._basis_ref[basis][q].phase.last_phase for q in last.targets - } - if len(ph_refs) != 1: - raise ValueError( - "Cannot do a multiple-target pulse on qubits with different " - "phase references for the same basis." - ) - else: - phase_ref = ph_refs.pop() - - pulse = self._validate_and_adjust_pulse(pulse, channel, phase_ref) - - phase_barriers = [ - self._basis_ref[basis][q].phase.last_time for q in last.targets - ] - - self._schedule.add_pulse(pulse, channel, phase_barriers, protocol) - - true_finish = self._last(channel).tf + pulse.fall_time(channel_obj) - for qubit in last.targets: - self._basis_ref[basis][qubit].update_last_used(true_finish) - - if pulse.post_phase_shift: - self._phase_shift( - pulse.post_phase_shift, *last.targets, basis=basis - ) + Note: + When the phase of the pulse to add is different than the phase of + the previous pulse on the channel, a delay between the two pulses + might be automatically added to ensure the channel's + `phase_jump_time` is respected. To override this behaviour, use + the ``'no-delay'`` protocol. + """ + self._validate_channel(channel, block_eom_mode=True) + self._add(pulse, channel, protocol) @seq_decorators.store def target( @@ -799,7 +1086,7 @@ def build( self, *, qubits: Optional[Mapping[QubitId, int]] = None, - **vars: Union[ArrayLike, float, int, str], + **vars: Union[ArrayLike, float, int], ) -> Sequence: """Builds a sequence from the programmed instructions. @@ -841,7 +1128,7 @@ def build( # Shallow copy with stored parametrized objects (if any) # NOTE: While seq is a shallow copy, be extra careful with changes to - # atributes of seq pointing to mutable objects, as they might be + # attributes of seq pointing to mutable objects, as they might be # inadvertedly done to self too seq = copy.copy(self) @@ -1055,6 +1342,53 @@ def draw( fig.savefig(fig_name, **kwargs_savefig) plt.show() + def _add( + self, + pulse: Union[Pulse, Parametrized], + channel: str, + protocol: PROTOCOLS, + ) -> None: + self._validate_add_protocol(protocol) + if self.is_parametrized(): + if not isinstance(pulse, Parametrized): + self._validate_and_adjust_pulse(pulse, channel) + return + + pulse = cast(Pulse, pulse) + channel_obj = self._schedule[channel].channel_obj + last = self._last(channel) + basis = channel_obj.basis + + ph_refs = { + self._basis_ref[basis][q].phase.last_phase for q in last.targets + } + if len(ph_refs) != 1: + raise ValueError( + "Cannot do a multiple-target pulse on qubits with different " + "phase references for the same basis." + ) + else: + phase_ref = ph_refs.pop() + + pulse = self._validate_and_adjust_pulse(pulse, channel, phase_ref) + + phase_barriers = [ + self._basis_ref[basis][q].phase.last_time for q in last.targets + ] + + self._schedule.add_pulse(pulse, channel, phase_barriers, protocol) + + true_finish = self._last(channel).tf + pulse.fall_time( + channel_obj, in_eom_mode=self.is_in_eom_mode(channel) + ) + for qubit in last.targets: + self._basis_ref[basis][qubit].update_last_used(true_finish) + + if pulse.post_phase_shift: + self._phase_shift( + pulse.post_phase_shift, *last.targets, basis=basis + ) + @seq_decorators.block_if_measured def _target( self, @@ -1062,7 +1396,7 @@ def _target( channel: str, _index: bool = False, ) -> None: - self._validate_channel(channel) + self._validate_channel(channel, block_eom_mode=True) channel_obj = self._schedule[channel].channel_obj try: qubits_set = ( @@ -1075,7 +1409,10 @@ def _target( if channel_obj.addressing != "Local": raise ValueError("Can only choose target of 'Local' channels.") - elif len(qubits_set) > cast(int, channel_obj.max_targets): + elif ( + channel_obj.max_targets is not None + and len(qubits_set) > channel_obj.max_targets + ): raise ValueError( f"This channel can target at most {channel_obj.max_targets} " "qubits at a time." @@ -1176,7 +1513,9 @@ def _last(self, channel: str) -> _TimeSlot: """Shortcut to last element in the channel's schedule.""" return self._schedule[channel][-1] - def _validate_channel(self, channel: str) -> None: + def _validate_channel( + self, channel: str, block_eom_mode: bool = False + ) -> None: if isinstance(channel, Parametrized): raise NotImplementedError( "Using parametrized objects or variables to refer to channels " @@ -1184,12 +1523,14 @@ def _validate_channel(self, channel: str) -> None: ) if channel not in self._schedule: raise ValueError("Use the name of a declared channel.") + if block_eom_mode and self.is_in_eom_mode(channel): + raise RuntimeError("The chosen channel is in EOM mode.") def _validate_and_adjust_pulse( self, pulse: Pulse, channel: str, phase_ref: Optional[float] = None ) -> Pulse: - self._device.validate_pulse(pulse, self._schedule[channel].channel_id) channel_obj = self._schedule[channel].channel_obj + channel_obj.validate_pulse(pulse) _duration = channel_obj.validate_duration(pulse.duration) new_phase = pulse.phase + (phase_ref if phase_ref else 0) if _duration != pulse.duration: @@ -1209,6 +1550,14 @@ def _validate_and_adjust_pulse( return Pulse(new_amp, new_det, new_phase, pulse.post_phase_shift) + def _validate_add_protocol(self, protocol: str) -> None: + valid_protocols = get_args(PROTOCOLS) + if protocol not in valid_protocols: + raise ValueError( + f"Invalid protocol '{protocol}', only accepts protocols: " + + ", ".join(valid_protocols) + ) + def _reset_parametrized(self) -> None: """Resets all attributes related to parametrization.""" # Signals the sequence as actively "building" ie not parametrized diff --git a/pulser-core/pulser/waveforms.py b/pulser-core/pulser/waveforms.py index 2bdefbbbf..13e806885 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -23,7 +23,7 @@ from abc import ABC, abstractmethod from sys import version_info from types import FunctionType -from typing import Any, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Optional, Tuple, Union, cast import matplotlib.pyplot as plt import numpy as np @@ -31,13 +31,15 @@ from matplotlib.axes import Axes from numpy.typing import ArrayLike -from pulser.channels import Channel from pulser.json.abstract_repr.serializer import abstract_repr from pulser.json.exceptions import AbstractReprError from pulser.json.utils import obj_to_dict from pulser.parametrized import Parametrized, ParamObj from pulser.parametrized.decorators import parametrize +if TYPE_CHECKING: + from pulser.channels.base_channel import Channel + if version_info[:2] >= (3, 8): # pragma: no cover from functools import cached_property else: # pragma: no cover @@ -161,29 +163,36 @@ def change_duration(self, new_duration: int) -> Waveform: " modifications to its duration." ) - def modulated_samples(self, channel: Channel) -> np.ndarray: + def modulated_samples( + self, channel: Channel, eom: bool = False + ) -> np.ndarray: """The waveform samples as output of a given channel. This duration is adjusted according to the minimal buffer times. Args: channel: The channel modulating the waveform. + eom: Whether to modulate for the EOM. Returns: The array of samples after modulation. """ start, end = self.modulation_buffers(channel) - mod_samples = self._modulated_samples(channel) + mod_samples = self._modulated_samples(channel, eom=eom) tr = channel.rise_time trim = slice(tr - start, len(mod_samples) - tr + end) return mod_samples[trim] @functools.lru_cache() - def modulation_buffers(self, channel: Channel) -> tuple[int, int]: + def modulation_buffers( + self, channel: Channel, eom: bool = False + ) -> tuple[int, int]: """The minimal buffers needed around a modulated waveform. Args: channel: The channel modulating the waveform. + eom: Whether to calculate the modulation buffers with + the EOM bandwidth. Returns: The minimum buffer times at the start and end of @@ -193,11 +202,13 @@ def modulation_buffers(self, channel: Channel) -> tuple[int, int]: return 0, 0 return channel.calc_modulation_buffer( - self._samples, self._modulated_samples(channel) + self._samples, self._modulated_samples(channel, eom=eom), eom=eom ) @functools.lru_cache() - def _modulated_samples(self, channel: Channel) -> np.ndarray: + def _modulated_samples( + self, channel: Channel, eom: bool = False + ) -> np.ndarray: """The waveform samples as output of a given channel. This is not adjusted to the minimal buffer times. Use @@ -205,11 +216,12 @@ def _modulated_samples(self, channel: Channel) -> np.ndarray: Args: channel: The channel modulating the waveform. + eom: Whether to modulate for the EOM. Returns: The array of samples after modulation. """ - return channel.modulate(self._samples) + return channel.modulate(self._samples, eom=eom) @abstractmethod def _to_dict(self) -> dict[str, Any]: diff --git a/pulser-pasqal/LICENSE b/pulser-pasqal/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/pulser-pasqal/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pulser-pasqal/MANIFEST.in b/pulser-pasqal/MANIFEST.in new file mode 100644 index 000000000..04f196ac7 --- /dev/null +++ b/pulser-pasqal/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include LICENSE diff --git a/pulser-pasqal/README.md b/pulser-pasqal/README.md new file mode 100644 index 000000000..bdf46c2a2 --- /dev/null +++ b/pulser-pasqal/README.md @@ -0,0 +1,21 @@ +# pulser-pasqal + +[Pulser](https://pypi.org/project/pulser/) is a framework for composing, simulating and executing **pulse** sequences for neutral-atom quantum devices. + +This is the `pulser-pasqal` extension, which provides the functionalities needed to execute `pulser` sequences on [Pasqal](https://pasqal.io/)'s backends. + +## Installation + +The standard Pulser installation, + +```bash +pip install pulser +``` + +will automatically install `pulser-pasqal`. If you wish to install it on its own, you can also run + +```bash +pip install pulser-pasqal +``` + +Note that `pulser-core` is a requirement of `pulser-pasqal`, so it will be installed if it hasn't been already. diff --git a/pulser-pasqal/pulser_pasqal/__init__.py b/pulser-pasqal/pulser_pasqal/__init__.py new file mode 100644 index 000000000..f3bc2f54b --- /dev/null +++ b/pulser-pasqal/pulser_pasqal/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Classes for interfacing with Pasqal backends.""" + +from sdk import Configuration, DeviceType, Endpoints + +from pulser_pasqal._version import __version__ +from pulser_pasqal.job_parameters import JobParameters, JobVariables +from pulser_pasqal.pasqal_cloud import PasqalCloud diff --git a/pulser-pasqal/pulser_pasqal/_version.py b/pulser-pasqal/pulser_pasqal/_version.py new file mode 100644 index 000000000..08126f2ae --- /dev/null +++ b/pulser-pasqal/pulser_pasqal/_version.py @@ -0,0 +1,20 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from pathlib import PurePath + +# Sets the version to the same as 'pulser'. +version_file_path = PurePath(__file__).parent.parent.parent / "VERSION.txt" + +with open(version_file_path, "r") as f: + __version__ = f.read().strip() diff --git a/pulser-pasqal/pulser_pasqal/job_parameters.py b/pulser-pasqal/pulser_pasqal/job_parameters.py new file mode 100644 index 000000000..ab774ce8e --- /dev/null +++ b/pulser-pasqal/pulser_pasqal/job_parameters.py @@ -0,0 +1,65 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Parameters to build a sequence sent to the cloud.""" +from __future__ import annotations + +import dataclasses +from typing import Dict, Mapping, Optional, Union + +from numpy.typing import ArrayLike + +from pulser.register import QubitId + +JobVariablesDict = Dict[str, Union[ArrayLike, Optional[Mapping[QubitId, int]]]] + + +class JobVariables: + """Variables to build the sequence.""" + + def __init__( + self, + qubits: Optional[Mapping[QubitId, int]] = None, + **vars: Union[ArrayLike, float, int], + ): + """Initializes the JobVariables class. + + Args: + qubits: A mapping between qubit IDs and trap IDs used to define + the register. Must only be provided when the sequence is + initialized with a MappableRegister. + vars: The values for all the variables declared in this Sequence + instance, indexed by the name given upon declaration. Check + ``Sequence.declared_variables`` to see all the variables. + """ + self._qubits = qubits + self._vars = vars + + def get_dict(self) -> JobVariablesDict: + """Creates a dictionary used by the Sequence building and the cloud.""" + return {"qubits": self._qubits, **self._vars} + + +@dataclasses.dataclass +class JobParameters: + """Parameters representing a job to build the sequence.""" + + runs: int + variables: JobVariables + + def get_dict(self) -> dict[str, Union[int, JobVariablesDict]]: + """Creates a dictionary to send to the cloud.""" + return dict( + runs=self.runs, + variables=self.variables.get_dict(), + ) diff --git a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py new file mode 100644 index 000000000..503dec741 --- /dev/null +++ b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py @@ -0,0 +1,119 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Allows to connect to the cloud powered by Pasqal to run sequences.""" +from __future__ import annotations + +from typing import Any, Optional + +import sdk + +from pulser import Sequence +from pulser.devices import Device +from pulser_pasqal.job_parameters import JobParameters + + +class PasqalCloud: + """Manager of the connection to the cloud powered by Pasqal. + + The cloud connection enables to run sequences on simulators or on real + QPUs. + + Args: + client_id: client_id of the API key you are holding for Pasqal + cloud. + client_secret: client_secret of the API key you are holding for + Pasqal cloud. + kwargs: Additional arguments to provide to SDK + """ + + def __init__( + self, + client_id: str, + client_secret: str, + **kwargs: Any, + ): + """Initializes a connection to the cloud.""" + self._sdk_connection = sdk.SDK( + client_id=client_id, + client_secret=client_secret, + **kwargs, + ) + + def create_batch( + self, + seq: Sequence, + jobs: list[JobParameters], + device_type: sdk.DeviceType = sdk.DeviceType.QPU, + configuration: Optional[sdk.Configuration] = None, + wait: bool = False, + ) -> sdk.Batch: + """Create a new batch and send it to the API. + + For Iroise MVP, the batch must contain at least one job and will be + declared as complete immediately. + + Args: + seq: Pulser sequence. + jobs: List of jobs to be added to the batch at creation. + device_type: The type of device to use, either an emulator or a QPU + If set to QPU, the device_type will be set to the one + stored in the serialized sequence. + configuration: A dictionary with extra configuration for the + emulators that accept it. + wait: Whether to wait for results to be sent back. + + Returns: + Batch: The new batch that has been created in the database. + """ + if device_type == sdk.DeviceType.QPU and not isinstance( + seq.device, Device + ): + raise TypeError( + "To be sent to a real QPU, the device of the sequence " + "must be a real device, instance of 'Device'." + ) + + for params in jobs: + seq.build(**params.variables.get_dict()) # type: ignore + + return self._sdk_connection.create_batch( + serialized_sequence=self._serialize_seq( + seq=seq, device_type=device_type + ), + jobs=[j.get_dict() for j in jobs], + device_type=device_type, + configuration=configuration, + wait=wait, + ) + + def _serialize_seq( + self, seq: Sequence, device_type: sdk.DeviceType + ) -> str: + if device_type == sdk.DeviceType.QPU: + return seq.serialize() + return seq.to_abstract_repr() + + def get_batch(self, id: int, fetch_results: bool = False) -> sdk.Batch: + """Retrieve a batch's data and all its jobs. + + Args: + id: Id of the batch. + fetch_results: Whether to load job results. + + Returns: + Batch: The batch stored in the database. + """ + return self._sdk_connection.get_batch( + id=id, fetch_results=fetch_results + ) diff --git a/pulser-pasqal/pulser_pasqal/py.typed b/pulser-pasqal/pulser_pasqal/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/pulser-pasqal/requirements.txt b/pulser-pasqal/requirements.txt new file mode 100644 index 000000000..00eb7bcd2 --- /dev/null +++ b/pulser-pasqal/requirements.txt @@ -0,0 +1 @@ +pasqal-sdk == 0.1.6 diff --git a/pulser-pasqal/rtd_requirements.txt b/pulser-pasqal/rtd_requirements.txt new file mode 100644 index 000000000..6d5a1ae77 --- /dev/null +++ b/pulser-pasqal/rtd_requirements.txt @@ -0,0 +1 @@ +pulser-pasqal/. diff --git a/pulser-pasqal/setup.py b/pulser-pasqal/setup.py new file mode 100644 index 000000000..8629b4de0 --- /dev/null +++ b/pulser-pasqal/setup.py @@ -0,0 +1,70 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +from pathlib import Path + +from setuptools import find_packages, setup + +distribution_name = "pulser-pasqal" # The name on PyPI +package_name = "pulser_pasqal" # The main module name +description = ( + "A Pulser extension to execute pulse-level sequences on Pasqal" + " backends." +) +current_directory = Path(__file__).parent + +# Reads the version from the VERSION.txt file +with open(current_directory.parent / "VERSION.txt", "r") as f: + __version__ = f.read().strip() + +# Changes to the directory where setup.py is +os.chdir(current_directory) + +with open("requirements.txt") as f: + requirements = f.read().splitlines() +requirements.append(f"pulser-core=={__version__}") + +# Stashes the source code for the local version file +local_version_fpath = Path(package_name) / "_version.py" +with open(local_version_fpath, "r") as f: + stashed_version_source = f.read() + +# Overwrites the _version.py for the source distribution (reverted at the end) +with open(local_version_fpath, "w") as f: + f.write(f"__version__ = '{__version__}'\n") + +setup( + name=distribution_name, + version=__version__, + install_requires=requirements, + packages=find_packages(), + package_data={package_name: ["py.typed"]}, + include_package_data=True, + description=description, + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + author="Pulser Development Team", + python_requires=">=3.7.0", + license="Apache 2.0", + classifiers=[ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + ], + url="https://github.com/pasqal-io/Pulser", + zip_safe=False, +) + +# Restores the original source code of _version.py +with open(local_version_fpath, "w") as f: + f.write(stashed_version_source) diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index a20735666..2671a422f 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -650,7 +650,7 @@ def make_xy_term(q1: QubitId, q2: QubitId) -> qutip.Qobj: ) / (dist * mag_norm) U = ( 0.5 - * self._seq._device.interaction_coeff_xy + * cast(float, self._seq._device.interaction_coeff_xy) * (1 - 3 * cosine**2) / dist**3 ) @@ -819,7 +819,7 @@ def get_hamiltonian(self, time: float) -> qutip.Qobj: def run( self, progress_bar: Optional[bool] = False, - **options: qutip.solver.Options, + **options: Any, ) -> SimulationResults: """Simulates the sequence using QuTiP's solvers. @@ -829,10 +829,14 @@ def run( Args: progress_bar: If True, the progress bar of QuTiP's solver will be shown. If None or False, no text appears. - options: If specified, will override - SimConfig solver_options. If no `max_step` value is provided, - an automatic one is calculated from the `Sequence`'s schedule - (half of the shortest duration among pulses and delays). + options: Used as arguments for qutip.Options(). If specified, will + override SimConfig solver_options. If no `max_step` value is + provided, an automatic one is calculated from the `Sequence`'s + schedule (half of the shortest duration among pulses and + delays). + Refer to the QuTiP docs_ for an overview of the parameters. + + .. _docs: https://bit.ly/3il9A2u """ if "max_step" in options.keys(): solv_ops = qutip.Options(**options) diff --git a/pulser-simulation/requirements.txt b/pulser-simulation/requirements.txt index 7ccd7f816..e296eb4a3 100644 --- a/pulser-simulation/requirements.txt +++ b/pulser-simulation/requirements.txt @@ -1,2 +1,2 @@ -qutip>=4.6.3 +qutip>=4.7.1 typing-extensions; python_version == '3.7' diff --git a/pyproject.toml b/pyproject.toml index c0f15345a..7db90a088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,4 @@ line-length = 79 [tool.isort] profile = "black" line_length = 79 -src_paths = ["pulser-core", "pulser-simulation"] +src_paths = ["pulser-core", "pulser-simulation", "pulser-pasqal"] diff --git a/setup.py b/setup.py index fc1a95c21..d58354def 100644 --- a/setup.py +++ b/setup.py @@ -22,13 +22,13 @@ raise RuntimeError( "The 'pulser' distribution can only be installed or packaged for " "stable versions. To install the full development version, run " - "`pip install -e ./pulser-core -e ./pulser-simulation` instead." + "`make dev-install` instead." ) with open("packages.txt", "r") as f: requirements = [f"{pkg.strip()}=={__version__}" for pkg in f.readlines()] -# Just a meta-package that requires 'pulser-core' and 'pulser-simulation' +# Just a meta-package that requires all pulser packages setup( name="pulser", version=__version__, diff --git a/tests/conftest.py b/tests/conftest.py index adaf1cc2c..dfd27932f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ import pytest from pulser.channels import Raman, Rydberg +from pulser.channels.eom import RydbergBeam, RydbergEOM from pulser.devices import Device @@ -28,6 +29,7 @@ def mod_device() -> Device: max_atom_num=2000, max_radial_distance=1000, min_atom_distance=1, + supports_slm_mask=True, _channels=( ( "rydberg_global", @@ -38,6 +40,13 @@ def mod_device() -> Device: clock_period=1, min_duration=1, mod_bandwidth=4.0, # MHz + eom_config=RydbergEOM( + mod_bandwidth=30.0, + limiting_beam=RydbergBeam.RED, + max_limiting_amp=50 * 2 * np.pi, + intermediate_detuning=800 * 2 * np.pi, + controlled_beams=(RydbergBeam.BLUE,), + ), ), ), ( @@ -47,10 +56,17 @@ def mod_device() -> Device: 2 * np.pi * 20, 2 * np.pi * 10, max_targets=2, - phase_jump_time=0, fixed_retarget_t=0, + clock_period=4, min_retarget_interval=220, mod_bandwidth=4.0, + eom_config=RydbergEOM( + mod_bandwidth=20.0, + limiting_beam=RydbergBeam.RED, + max_limiting_amp=60 * 2 * np.pi, + intermediate_detuning=700 * 2 * np.pi, + controlled_beams=tuple(RydbergBeam), + ), ), ), ( @@ -60,9 +76,9 @@ def mod_device() -> Device: 2 * np.pi * 20, 2 * np.pi * 10, max_targets=2, - phase_jump_time=0, fixed_retarget_t=0, min_retarget_interval=220, + clock_period=4, mod_bandwidth=4.0, ), ), diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 6e026669c..ee99bc99e 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -23,8 +23,12 @@ import pytest from pulser import Pulse, Register, Register3D, Sequence, devices -from pulser.devices import Chadoq2, MockDevice -from pulser.json.abstract_repr.deserializer import VARIABLE_TYPE_MAP +from pulser.devices import Chadoq2, IroiseMVP, MockDevice +from pulser.json.abstract_repr.deserializer import ( + VARIABLE_TYPE_MAP, + deserialize_device, + resolver, +) from pulser.json.abstract_repr.serializer import ( AbstractReprEncoder, abstract_repr, @@ -53,6 +57,32 @@ } +class TestDevice: + @pytest.fixture(params=[Chadoq2, IroiseMVP, MockDevice]) + def abstract_device(self, request): + device = request.param + return json.loads(device.to_abstract_repr()) + + def test_device_schema(self, abstract_device): + with open( + "pulser-core/pulser/json/abstract_repr/schemas/device-schema.json" + ) as f: + dev_schema = json.load(f) + jsonschema.validate(instance=abstract_device, schema=dev_schema) + + def test_roundtrip(self, abstract_device): + device = deserialize_device(json.dumps(abstract_device)) + assert json.loads(device.to_abstract_repr()) == abstract_device + + +def validate_schema(instance): + with open( + "pulser-core/pulser/json/abstract_repr/schemas/" "sequence-schema.json" + ) as f: + schema = json.load(f) + jsonschema.validate(instance=instance, schema=schema, resolver=resolver) + + class TestSerialization: @pytest.fixture def triangular_lattice(self): @@ -113,9 +143,7 @@ def abstract(self, sequence): ) def test_schema(self, abstract): - with open("pulser-core/pulser/json/abstract_repr/schema.json") as f: - schema = json.load(f) - jsonschema.validate(instance=abstract, schema=schema) + validate_schema(abstract) def test_values(self, abstract): assert set(abstract.keys()) == set( @@ -130,9 +158,13 @@ def test_values(self, abstract): "measurement", ] ) - assert abstract["device"] in [ + device_name = abstract["device"]["name"] + assert abstract["device"]["name"] in [ d.name for d in [*devices._valid_devices, *devices._mock_devices] ] + assert abstract["device"] == json.loads( + getattr(devices, device_name).to_abstract_repr() + ) assert abstract["register"] == [ {"name": "control", "x": -2.0, "y": 0.0}, {"name": "target", "x": 2.0, "y": 0.0}, @@ -382,12 +414,14 @@ def test_mw_sequence(self, triangular_lattice): seq.measure("XY") abstract = json.loads(seq.to_abstract_repr()) + validate_schema(abstract) assert abstract["register"] == [ {"name": str(qid), "x": c[0], "y": c[1]} for qid, c in reg.qubits.items() ] assert abstract["layout"] == { - "coordinates": triangular_lattice.coords.tolist() + "coordinates": triangular_lattice.coords.tolist(), + "slug": triangular_lattice.slug, } assert abstract["magnetic_field"] == mag_field assert abstract["slm_mask_targets"] == list(mask) @@ -399,8 +433,10 @@ def test_mappable_register(self, triangular_lattice): _ = seq.declare_variable("var", dtype=int) abstract = json.loads(seq.to_abstract_repr()) + validate_schema(abstract) assert abstract["layout"] == { - "coordinates": triangular_lattice.coords.tolist() + "coordinates": triangular_lattice.coords.tolist(), + "slug": triangular_lattice.slug, } assert abstract["register"] == [{"qid": qid} for qid in reg.qubit_ids] assert abstract["variables"]["var"] == dict(type="int") @@ -421,6 +457,53 @@ def test_mappable_register(self, triangular_lattice): ] assert abstract["variables"]["var"] == dict(type="int", value=[0]) + def test_eom_mode(self, triangular_lattice): + reg = triangular_lattice.hexagonal_register(7) + seq = Sequence(reg, IroiseMVP) + seq.declare_channel("ryd", "rydberg_global") + det_off = seq.declare_variable("det_off", dtype=float) + duration = seq.declare_variable("duration", dtype=int) + seq.enable_eom_mode( + "ryd", amp_on=3.0, detuning_on=0.0, optimal_detuning_off=det_off + ) + seq.add_eom_pulse("ryd", duration, 0.0) + seq.delay(duration, "ryd") + seq.disable_eom_mode("ryd") + + abstract = json.loads(seq.to_abstract_repr()) + validate_schema(abstract) + + assert abstract["operations"][0] == { + "op": "enable_eom_mode", + "channel": "ryd", + "amp_on": 3.0, + "detuning_on": 0.0, + "optimal_detuning_off": { + "expression": "index", + "lhs": {"variable": "det_off"}, + "rhs": 0, + }, + } + + ser_duration = { + "expression": "index", + "lhs": {"variable": "duration"}, + "rhs": 0, + } + assert abstract["operations"][1] == { + "op": "add_eom_pulse", + "channel": "ryd", + "duration": ser_duration, + "phase": 0.0, + "post_phase_shift": 0.0, + "protocol": "min-delay", + } + + assert abstract["operations"][3] == { + "op": "disable_eom_mode", + "channel": "ryd", + } + def _get_serialized_seq( operations: list[dict] = None, @@ -431,7 +514,7 @@ def _get_serialized_seq( seq_dict = { "version": "1", "name": "John Doe", - "device": "Chadoq2", + "device": json.loads(Chadoq2.to_abstract_repr()), "register": [ {"name": "q0", "x": 0.0, "y": 2.0}, {"name": "q42", "x": -2.0, "y": 9.0}, @@ -502,8 +585,8 @@ def test_deserialize_device_and_channels(self) -> None: _check_roundtrip(s) seq = Sequence.from_abstract_repr(json.dumps(s)) - # Check device name - assert seq._device.name == s["device"] + # Check device + assert seq._device == deserialize_device(json.dumps(s["device"])) # Check channels assert len(seq.declared_channels) == len(s["channels"]) @@ -547,7 +630,10 @@ def test_deserialize_mappable_register(self): layout_coords = (5 * np.arange(8)).reshape((4, 2)) s = _get_serialized_seq( register=[{"qid": "q0"}, {"qid": "q1", "default_trap": 2}], - layout={"coordinates": layout_coords.tolist()}, + layout={ + "coordinates": layout_coords.tolist(), + "slug": "test_layout", + }, ) _check_roundtrip(s) seq = Sequence.from_abstract_repr(json.dumps(s)) @@ -567,7 +653,7 @@ def test_deserialize_seq_with_mag_field(self): mag_field = [10.0, -43.2, 0.0] s = _get_serialized_seq( magnetic_field=mag_field, - device="MockDevice", + device=json.loads(MockDevice.to_abstract_repr()), channels={"mw": "mw_global"}, ) _check_roundtrip(s) @@ -882,6 +968,65 @@ def test_deserialize_parametrized_op(self, op): else: assert False, f"operation type \"{op['op']}\" is not valid" + def test_deserialize_eom_ops(self): + s = _get_serialized_seq( + operations=[ + { + "op": "enable_eom_mode", + "channel": "global", + "amp_on": 3.0, + "detuning_on": 0.0, + "optimal_detuning_off": -1.0, + }, + { + "op": "add_eom_pulse", + "channel": "global", + "duration": { + "expression": "index", + "lhs": {"variable": "duration"}, + "rhs": 0, + }, + "phase": 0.0, + "post_phase_shift": 0.0, + "protocol": "no-delay", + }, + { + "op": "disable_eom_mode", + "channel": "global", + }, + ], + variables={"duration": {"type": "int", "value": [100]}}, + device=json.loads(IroiseMVP.to_abstract_repr()), + channels={"global": "rydberg_global"}, + ) + _check_roundtrip(s) + seq = Sequence.from_abstract_repr(json.dumps(s)) + # init + declare_channel + enable_eom_mode + assert len(seq._calls) == 3 + # add_eom_pulse + disable_eom + assert len(seq._to_build_calls) == 2 + + enable_eom_call = seq._calls[-1] + assert enable_eom_call.name == "enable_eom_mode" + assert enable_eom_call.kwargs == { + "channel": "global", + "amp_on": 3.0, + "detuning_on": 0.0, + "optimal_detuning_off": -1.0, + } + + disable_eom_call = seq._to_build_calls[-1] + assert disable_eom_call.name == "disable_eom_mode" + assert disable_eom_call.kwargs == {"channel": "global"} + + eom_pulse_call = seq._to_build_calls[0] + assert eom_pulse_call.name == "add_eom_pulse" + assert eom_pulse_call.kwargs["channel"] == "global" + assert isinstance(eom_pulse_call.kwargs["duration"], VariableItem) + assert eom_pulse_call.kwargs["phase"] == 0.0 + assert eom_pulse_call.kwargs["post_phase_shift"] == 0.0 + assert eom_pulse_call.kwargs["protocol"] == "no-delay" + @pytest.mark.parametrize( "wf_obj", [ @@ -1202,3 +1347,11 @@ def test_unknow_waveform(self): ): with patch("jsonschema.validate"): Sequence.from_abstract_repr(json.dumps(s)) + + @pytest.mark.parametrize("device", [Chadoq2, IroiseMVP, MockDevice]) + def test_legacy_device(self, device): + s = _get_serialized_seq( + device=device.name, channels={"global": "rydberg_global"} + ) + seq = Sequence.from_abstract_repr(json.dumps(s)) + assert seq.device == device diff --git a/tests/test_channels.py b/tests/test_channels.py index 532be943f..78b13f253 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -12,21 +12,105 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + import numpy as np import pytest import pulser -from pulser.channels import Raman, Rydberg +from pulser.channels import Microwave, Raman, Rydberg +from pulser.channels.eom import BaseEOM, RydbergBeam, RydbergEOM from pulser.waveforms import BlackmanWaveform, ConstantWaveform +@pytest.mark.parametrize( + "bad_param,bad_value", + [ + ("max_amp", 0), + ("max_abs_detuning", -0.001), + ("clock_period", 0), + ("min_duration", 0), + ("max_duration", 0), + ("mod_bandwidth", 0), + ], +) +def test_bad_init_global_channel(bad_param, bad_value): + kwargs = dict(max_abs_detuning=None, max_amp=None) + kwargs[bad_param] = bad_value + with pytest.raises(ValueError, match=f"'{bad_param}' must be"): + Microwave.Global(**kwargs) + + +@pytest.mark.parametrize( + "bad_param,bad_value", + [ + ("max_amp", -0.0001), + ("max_abs_detuning", -1e6), + ("min_retarget_interval", -1), + ("fixed_retarget_t", -1), + ("max_targets", 0), + ("clock_period", -4), + ("min_duration", -2), + ("max_duration", -1), + ("mod_bandwidth", -1e4), + ], +) +def test_bad_init_local_channel(bad_param, bad_value): + kwargs = dict(max_abs_detuning=None, max_amp=None) + kwargs[bad_param] = bad_value + with pytest.raises(ValueError, match=f"'{bad_param}' must be"): + Rydberg.Local(**kwargs) + + +def test_bad_durations(): + max_duration, min_duration = 10, 16 + with pytest.raises( + ValueError, + match=re.escape( + f"When defined, 'max_duration'({max_duration}) must be" + f" greater than or equal to 'min_duration'({min_duration})." + ), + ): + Rydberg.Global( + None, None, min_duration=min_duration, max_duration=max_duration + ) + + +@pytest.mark.parametrize( + "field", + [ + "min_retarget_interval", + "fixed_retarget_t", + ], +) +def test_bad_none_fields(field): + with pytest.raises( + TypeError, match=f"'{field}' can't be None in a 'Local' channel." + ): + Raman.Local(None, None, **{field: None}) + + +@pytest.mark.parametrize("max_amp", [1, None]) +@pytest.mark.parametrize("max_abs_detuning", [0, None]) +@pytest.mark.parametrize("max_duration", [1000, None]) +@pytest.mark.parametrize("max_targets", [1, None]) +def test_virtual_channel(max_amp, max_abs_detuning, max_duration, max_targets): + params = (max_amp, max_abs_detuning, max_duration, max_targets) + assert Raman.Local( + max_amp=max_amp, + max_abs_detuning=max_abs_detuning, + max_duration=max_duration, + max_targets=max_targets, + ).is_virtual() == (None in params) + + def test_device_channels(): for dev in pulser.devices._valid_devices: for i, (id, ch) in enumerate(dev.channels.items()): assert id == dev._channels[i][0] assert isinstance(id, str) assert ch == dev._channels[i][1] - assert isinstance(ch, pulser.channels.Channel) + assert isinstance(ch, pulser.channels.channels.Channel) assert ch.name in ["Rydberg", "Raman"] assert ch.basis in ["digital", "ground-rydberg"] assert ch.addressing in ["Local", "Global"] @@ -57,34 +141,78 @@ def test_validate_duration(): def test_repr(): raman = Raman.Local( - 10, 2, min_retarget_interval=1000, fixed_retarget_t=200, max_targets=4 + None, + 2, + min_retarget_interval=1000, + fixed_retarget_t=200, + max_targets=4, + min_duration=16, + clock_period=4, + max_duration=None, ) r1 = ( - "Raman.Local(Max Absolute Detuning: 10 rad/µs, Max Amplitude: " - "2 rad/µs, Phase Jump Time: 0 ns, Minimum retarget time: 1000 ns, " - "Fixed retarget time: 200 ns, Max targets: 4, Basis: 'digital')" + "Raman.Local(Max Absolute Detuning: None, Max Amplitude: " + "2 rad/µs, Minimum retarget time: 1000 ns, " + "Fixed retarget time: 200 ns, Max targets: 4, Clock period: 4 ns, " + "Minimum pulse duration: 16 ns, Basis: 'digital')" ) assert raman.__str__() == r1 - ryd = Rydberg.Global(50, 2.5, phase_jump_time=300, mod_bandwidth=4) + ryd = Rydberg.Global(50, None, mod_bandwidth=4) r2 = ( "Rydberg.Global(Max Absolute Detuning: 50 rad/µs, " - "Max Amplitude: 2.5 rad/µs, Phase Jump Time: 300 ns, " - "Basis: 'ground-rydberg', Modulation Bandwidth: 4 MHz)" + "Max Amplitude: None, Clock period: 1 ns, " + "Minimum pulse duration: 1 ns, " + "Maximum pulse duration: 100000000 ns, " + "Modulation Bandwidth: 4 MHz, Basis: 'ground-rydberg')" ) assert ryd.__str__() == r2 -def test_modulation(): - rydberg_global = Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5) +_eom_config = RydbergEOM( + mod_bandwidth=20, + limiting_beam=RydbergBeam.RED, + max_limiting_amp=100 * 2 * np.pi, + intermediate_detuning=500 * 2 * np.pi, + controlled_beams=tuple(RydbergBeam), +) - raman_local = Raman.Local( - 2 * np.pi * 20, - 2 * np.pi * 10, - mod_bandwidth=4, # MHz - ) + +def test_eom_channel(): + with pytest.raises( + TypeError, + match="When defined, 'eom_config' must be a valid 'RydbergEOM'", + ): + Rydberg.Global(None, None, eom_config=BaseEOM(50)) + + with pytest.raises( + ValueError, + match="'eom_config' can't be defined in a Channel without a" + " modulation bandwidth", + ): + Rydberg.Global(None, None, eom_config=_eom_config) + + assert not Rydberg.Global(None, None).supports_eom() + assert Rydberg.Global( + None, None, mod_bandwidth=3, eom_config=_eom_config + ).supports_eom() + + +def test_modulation_errors(): wf = ConstantWaveform(100, 1) + no_eom_msg = "The channel Rydberg.Global(.*) does not have an EOM." + with pytest.raises(TypeError, match=no_eom_msg): + Rydberg.Global(None, None, mod_bandwidth=10).modulate( + wf.samples, eom=True + ) + + with pytest.raises(TypeError, match=no_eom_msg): + Rydberg.Global(None, None, mod_bandwidth=10).calc_modulation_buffer( + wf.samples, wf.samples, eom=True + ) + + rydberg_global = Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5) assert rydberg_global.mod_bandwidth is None with pytest.warns(UserWarning, match="No modulation bandwidth defined"): out_samples = rydberg_global.modulate(wf.samples) @@ -93,16 +221,41 @@ def test_modulation(): with pytest.raises(TypeError, match="doesn't have a modulation bandwidth"): rydberg_global.calc_modulation_buffer(wf.samples, out_samples) - out_ = raman_local.modulate(wf.samples) - tr = raman_local.rise_time + +_raman_local = Raman.Local( + 2 * np.pi * 20, + 2 * np.pi * 10, + mod_bandwidth=4, # MHz +) +_eom_rydberg = Rydberg.Global( + max_amp=2 * np.pi * 10, + max_abs_detuning=2 * np.pi * 5, + mod_bandwidth=10, + eom_config=_eom_config, +) + + +@pytest.mark.parametrize( + "channel, tr, eom, side_buffer_len", + [ + (_raman_local, _raman_local.rise_time, False, 45), + (_eom_rydberg, _eom_config.rise_time, True, 0), + ], +) +def test_modulation(channel, tr, eom, side_buffer_len): + + wf = ConstantWaveform(100, 1) + out_ = channel.modulate(wf.samples, eom=eom) assert len(out_) == wf.duration + 2 * tr - assert raman_local.calc_modulation_buffer(wf.samples, out_) == (tr, tr) + assert channel.calc_modulation_buffer(wf.samples, out_, eom=eom) == ( + tr, + tr, + ) wf2 = BlackmanWaveform(800, np.pi) - side_buffer_len = 45 - out_ = raman_local.modulate(wf2.samples) + out_ = channel.modulate(wf2.samples, eom=eom) assert len(out_) == wf2.duration + 2 * tr # modulate() does not truncate - assert raman_local.calc_modulation_buffer(wf2.samples, out_) == ( + assert channel.calc_modulation_buffer(wf2.samples, out_, eom=eom) == ( side_buffer_len, side_buffer_len, ) diff --git a/tests/test_devices.py b/tests/test_devices.py index 89c851bac..5c21d8895 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re from dataclasses import FrozenInstanceError from unittest.mock import patch @@ -19,13 +20,117 @@ import pytest import pulser -from pulser.devices import Chadoq2, Device +from pulser.channels import Microwave, Rydberg +from pulser.devices import Chadoq2, Device, VirtualDevice from pulser.register import Register, Register3D from pulser.register.register_layout import RegisterLayout from pulser.register.special_layouts import TriangularLatticeLayout -def test_init(): +@pytest.fixture +def test_params(): + return dict( + name="Test", + dimensions=2, + rydberg_level=70, + _channels=(), + min_atom_distance=1, + max_atom_num=None, + max_radial_distance=None, + ) + + +@pytest.mark.parametrize( + "param, value, msg", + [ + ("name", 1, None), + ("supports_slm_mask", 0, None), + ("reusable_channels", "true", None), + ("max_atom_num", 1e9, None), + ("max_radial_distance", 100.4, None), + ("rydberg_level", 70.0, "Rydberg level has to be an int."), + ( + "_channels", + ((1, Rydberg.Global(None, None)),), + "All channel IDs must be of type 'str', not 'int'", + ), + ( + "_channels", + (("ch1", "Rydberg.Global(None, None)"),), + "All channels must be of type 'Channel', not 'str'", + ), + ( + "_channels", + (("mw_ch", Microwave.Global(None, None)),), + "When the device has a 'Microwave' channel, " + "'interaction_coeff_xy' must be a 'float'," + " not ''.", + ), + ], +) +def test_post_init_type_checks(test_params, param, value, msg): + test_params[param] = value + error_msg = msg or f"{param} must be of type" + with pytest.raises(TypeError, match=error_msg): + VirtualDevice(**test_params) + + +@pytest.mark.parametrize( + "param, value, msg", + [ + ( + "dimensions", + 1, + re.escape("'dimensions' must be one of (2, 3), not 1."), + ), + ("rydberg_level", 49, "Rydberg level should be between 50 and 100."), + ("rydberg_level", 101, "Rydberg level should be between 50 and 100."), + ( + "min_atom_distance", + -0.001, + "'min_atom_distance' must be greater than or equal to zero", + ), + ("max_atom_num", 0, None), + ("max_radial_distance", 0, None), + ( + "max_layout_filling", + 0.0, + "maximum layout filling fraction must be greater than 0. and" + " less than or equal to 1.", + ), + ], +) +def test_post_init_value_errors(test_params, param, value, msg): + test_params[param] = value + error_msg = msg or f"When defined, '{param}' must be greater than zero" + with pytest.raises(ValueError, match=error_msg): + VirtualDevice(**test_params) + + +potential_params = ("max_atom_num", "max_radial_distance") + + +@pytest.mark.parametrize("none_param", potential_params) +def test_optional_parameters(test_params, none_param): + test_params.update({p: 10 for p in potential_params}) + test_params[none_param] = None + with pytest.raises( + TypeError, + match=f"'{none_param}' can't be None in a 'Device' instance.", + ): + Device(**test_params) + VirtualDevice(**test_params) # Valid as None on a VirtualDevice + + +def test_tuple_conversion(test_params): + test_params["_channels"] = ( + ["rydberg_global", Rydberg.Global(None, None)], + ) + dev = VirtualDevice(**test_params) + assert dev._channels == (("rydberg_global", Rydberg.Global(None, None)),) + + +def test_valid_devices(): for dev in pulser.devices._valid_devices: assert dev.dimensions in (2, 3) assert dev.rydberg_level > 49 @@ -34,7 +139,7 @@ def test_init(): assert dev.max_radial_distance > 10 assert dev.min_atom_distance > 0 assert dev.interaction_coeff > 0 - assert dev.interaction_coeff_xy > 0 + assert 0 < dev.max_layout_filling <= 1 assert isinstance(dev.channels, dict) with pytest.raises(FrozenInstanceError): dev.name = "something else" @@ -45,29 +150,6 @@ def test_init(): assert Chadoq2.__repr__() == "Chadoq2" -def test_mock(): - dev = pulser.devices.MockDevice - assert dev.dimensions == 3 - assert dev.rydberg_level > 49 - assert dev.rydberg_level < 101 - assert dev.max_atom_num > 1000 - assert dev.min_atom_distance <= 1 - assert dev.interaction_coeff > 0 - assert dev.interaction_coeff_xy == 3700 - names = ["Rydberg", "Raman", "Microwave"] - basis = ["ground-rydberg", "digital", "XY"] - for ch in dev.channels.values(): - assert ch.name in names - assert ch.basis == basis[names.index(ch.name)] - assert ch.addressing in ["Local", "Global"] - assert ch.max_abs_detuning >= 1000 - assert ch.max_amp >= 200 - if ch.addressing == "Local": - assert ch.min_retarget_interval == 0 - assert ch.max_targets > 1 - assert ch.max_targets == int(ch.max_targets) - - def test_change_rydberg_level(): dev = pulser.devices.MockDevice dev.change_rydberg_level(60) @@ -81,6 +163,13 @@ def test_change_rydberg_level(): dev.change_rydberg_level(110) dev.change_rydberg_level(70) + with pytest.warns(DeprecationWarning): + assert pulser.__version__ < "0.9" + og_ryd_level = Chadoq2.rydberg_level + Chadoq2.change_rydberg_level(60) + assert Chadoq2.rydberg_level == 60 + Chadoq2.change_rydberg_level(og_ryd_level) + def test_rydberg_blockade(): dev = pulser.devices.MockDevice @@ -115,16 +204,13 @@ def test_validate_register(): with pytest.raises( ValueError, match="associated with an incompatible register layout" ): - tri_layout = TriangularLatticeLayout(201, 5) + tri_layout = TriangularLatticeLayout(200, 20) Chadoq2.validate_register(tri_layout.hexagonal_register(10)) Chadoq2.validate_register(Register.rectangle(5, 10, spacing=5)) def test_validate_layout(): - with pytest.raises(ValueError, match="The number of traps"): - Chadoq2.validate_layout(RegisterLayout(Register.square(20)._coords)) - coords = [(100, 0), (-100, 0)] with pytest.raises(TypeError): Chadoq2.validate_layout(Register.from_coordinates(coords)) @@ -151,8 +237,38 @@ def test_validate_layout(): Chadoq2.validate_layout(valid_tri_layout) +@pytest.mark.parametrize( + "register", + [ + TriangularLatticeLayout(100, 5).hexagonal_register(80), + TriangularLatticeLayout(100, 5).make_mappable_register(51), + ], +) +def test_layout_filling(register): + assert Chadoq2.max_layout_filling == 0.5 + assert register.layout.number_of_traps == 100 + with pytest.raises( + ValueError, + match=re.escape( + "the given register has too many qubits " + f"({len(register.qubit_ids)}). " + "On this device, this layout can hold at most 50 qubits." + ), + ): + Chadoq2.validate_layout_filling(register) + + +def test_layout_filling_fail(): + with pytest.raises( + TypeError, + match="'validate_layout_filling' can only be called for" + " registers with a register layout.", + ): + Chadoq2.validate_layout_filling(Register.square(5)) + + def test_calibrated_layouts(): - with pytest.raises(ValueError, match="The number of traps"): + with pytest.raises(ValueError, match="The minimal distance between traps"): Device( name="TestDevice", dimensions=2, @@ -161,7 +277,7 @@ def test_calibrated_layouts(): max_radial_distance=50, min_atom_distance=4, _channels=(), - pre_calibrated_layouts=(TriangularLatticeLayout(201, 5),), + pre_calibrated_layouts=(TriangularLatticeLayout(201, 3),), ) TestDevice = Device( @@ -181,3 +297,36 @@ def test_calibrated_layouts(): "TriangularLatticeLayout(100, 6µm)", "TriangularLatticeLayout(200, 5µm)", } + + +def test_device_with_virtual_channel(): + with pytest.raises( + ValueError, + match="A 'Device' instance cannot contain virtual channels.", + ): + Device( + name="TestDevice", + dimensions=2, + rydberg_level=70, + max_atom_num=100, + max_radial_distance=50, + min_atom_distance=4, + _channels=(("rydberg_global", Rydberg.Global(None, 10)),), + ) + + +def test_convert_to_virtual(): + params = dict( + name="Test", + dimensions=2, + rydberg_level=80, + min_atom_distance=1, + max_atom_num=20, + max_radial_distance=40, + _channels=(("rydberg_global", Rydberg.Global(0, 10)),), + ) + assert Device( + pre_calibrated_layouts=(TriangularLatticeLayout(40, 2),), **params + ).to_virtual() == VirtualDevice( + supports_slm_mask=False, reusable_channels=False, **params + ) diff --git a/tests/test_eom.py b/tests/test_eom.py new file mode 100644 index 000000000..95e3d0dc7 --- /dev/null +++ b/tests/test_eom.py @@ -0,0 +1,131 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from pulser.channels.eom import RydbergBeam, RydbergEOM + + +@pytest.fixture +def params(): + return dict( + mod_bandwidth=1, + limiting_beam=RydbergBeam.RED, + max_limiting_amp=60, + intermediate_detuning=700, + controlled_beams=tuple(RydbergBeam), + ) + + +@pytest.mark.parametrize( + "bad_param,bad_value", + [ + ("mod_bandwidth", 0), + ("mod_bandwidth", -3), + ("max_limiting_amp", 0), + ("intermediate_detuning", -500), + ("intermediate_detuning", 0), + ], +) +def test_bad_value_init_eom(bad_param, bad_value, params): + params[bad_param] = bad_value + with pytest.raises( + ValueError, match=f"'{bad_param}' must be greater than zero" + ): + RydbergEOM(**params) + + +@pytest.mark.parametrize( + "bad_param,bad_value", + [ + ("limiting_beam", "red"), + ("limiting_beam", RydbergBeam), + ("limiting_beam", RydbergBeam.RED | RydbergBeam.BLUE), + ("controlled_beams", (RydbergBeam.RED | RydbergBeam.BLUE,)), + ("controlled_beams", (RydbergBeam,)), + ], +) +def test_bad_init_eom_beam(bad_param, bad_value, params): + params[bad_param] = bad_value + with pytest.raises( + TypeError, + match="Every beam must be one of options of the `RydbergBeam`", + ): + RydbergEOM(**params) + + +def test_bad_controlled_beam(params): + params["controlled_beams"] = set(RydbergBeam) + with pytest.raises( + TypeError, + match="The 'controlled_beams' must be provided as a tuple or list.", + ): + RydbergEOM(**params) + + params["controlled_beams"] = tuple() + with pytest.raises( + ValueError, + match="There must be at least one beam in 'controlled_beams'", + ): + RydbergEOM(**params) + + params["controlled_beams"] = list(RydbergBeam) + assert RydbergEOM(**params).controlled_beams == tuple(RydbergBeam) + + +@pytest.mark.parametrize("limit_amp_fraction", [0.5, 2]) +def test_detuning_off(limit_amp_fraction, params): + eom = RydbergEOM(**params) + limit_amp = params["max_limiting_amp"] ** 2 / ( + 2 * params["intermediate_detuning"] + ) + amp = limit_amp_fraction * limit_amp + + def calc_offset(amp): + # Manually calculates the offset needed to correct the lightshift + # coming from a difference in power between the beams + if amp <= limit_amp: + # Below limit_amp, red_amp=blue_amp so there is no lightshift + return 0.0 + assert params["limiting_beam"] == RydbergBeam.RED + red_amp = params["max_limiting_amp"] + blue_amp = 2 * params["intermediate_detuning"] * amp / red_amp + # The offset to have resonance when the pulse is on is -lightshift + return -(blue_amp**2 - red_amp**2) / ( + 4 * params["intermediate_detuning"] + ) + + # Case where the EOM pulses are resonant + detuning_on = 0.0 + zero_det = calc_offset(amp) # detuning when both beams are off = offset + assert eom._lightshift(amp, *RydbergBeam) == -zero_det + assert eom._lightshift(amp) == 0.0 + det_off_options = eom.detuning_off_options(amp, detuning_on) + det_off_options.sort() + assert det_off_options[0] < zero_det # RED on + assert det_off_options[1] == zero_det # All off + assert det_off_options[2] > zero_det # BLUE on + + # Case where the EOM pulses are off-resonant + detuning_on = 1.0 + for beam, ind in [(RydbergBeam.RED, 2), (RydbergBeam.BLUE, 0)]: + # When only one beam is controlled, there is a single + # detuning_off option + params["controlled_beams"] = (beam,) + eom_ = RydbergEOM(**params) + off_options = eom_.detuning_off_options(amp, detuning_on) + assert len(off_options) == 1 + # The new detuning_off is shifted by the new detuning_on, + # since that changes the offset compared the resonant case + assert off_options[0] == det_off_options[ind] + detuning_on diff --git a/tests/test_json.py b/tests/test_json.py index 35fe83721..f3129f563 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -50,6 +50,18 @@ def test_encoder(): encode(1j) +def test_device(mod_device): + assert encode_decode(Chadoq2) == Chadoq2 + with pytest.raises(SerializationError): + encode_decode(mod_device) + + +def test_virtual_device(mod_device): + assert encode_decode(MockDevice) == MockDevice + virtual_mod = mod_device.to_virtual() + assert encode_decode(virtual_mod) == virtual_mod + + def test_register_2d(): reg = Register({"c": (1, 2), "d": (8, 4)}) seq = Sequence(reg, device=Chadoq2) diff --git a/tests/test_paramseq.py b/tests/test_paramseq.py index cf75a1b83..aa7abe1a3 100644 --- a/tests/test_paramseq.py +++ b/tests/test_paramseq.py @@ -233,3 +233,95 @@ def test_screen(): sb.delay(var, "ch1") with pytest.raises(RuntimeError, match="can't be called in parametrized"): sb.current_phase_ref(4, basis="ground-rydberg") + + +def test_parametrized_in_eom_mode(mod_device): + # Case 1: Sequence becomes parametrized while in EOM mode + seq = Sequence(reg, mod_device) + seq.declare_channel("ch0", "rydberg_local", initial_target=0) + + assert not seq.is_in_eom_mode("ch0") + seq.enable_eom_mode("ch0", amp_on=2.0, detuning_on=0.0) + assert seq.is_in_eom_mode("ch0") + assert not seq.is_parametrized() + + dt = seq.declare_variable("dt", dtype=int) + seq.add_eom_pulse("ch0", dt, 0.0) + + assert seq.is_in_eom_mode("ch0") + assert seq.is_parametrized() + + with pytest.raises( + RuntimeError, match="The 'ch0' channel is already in EOM mode" + ): + seq.enable_eom_mode("ch0", amp_on=2.0, detuning_on=0.0) + + with pytest.raises( + RuntimeError, match="The chosen channel is in EOM mode" + ): + seq.target_index(1, "ch0") + + seq.disable_eom_mode("ch0") + assert not seq.is_in_eom_mode("ch0") + + with pytest.raises( + RuntimeError, match="The 'ch0' channel is not in EOM mode" + ): + seq.disable_eom_mode("ch0") + + # Just check that building works + seq.build(dt=100) + + +def test_parametrized_before_eom_mode(mod_device): + # Case 2: Sequence is parametrized before entering EOM mode + seq = Sequence(reg, mod_device) + + seq.declare_channel("ch0", "rydberg_local", initial_target=0) + seq.declare_channel("raman", "raman_local", initial_target=2) + amp = seq.declare_variable("amp", dtype=float) + seq.add(Pulse.ConstantPulse(200, amp, -1, 0), "ch0") + + assert not seq.is_in_eom_mode("ch0") + assert seq.is_parametrized() + + # Validation is still done whenever possible + with pytest.raises( + RuntimeError, match="Channel 'ch0' must be in EOM mode." + ): + seq.add_eom_pulse("ch0", 100, 0.0) + + with pytest.raises( + TypeError, match="Channel 'raman' does not have an EOM" + ): + seq.enable_eom_mode("raman", 1.0, 0.0) + + with pytest.raises( + ValueError, + match="The pulse's amplitude goes over the maximum " + "value allowed for the chosen channel.", + ): + seq.enable_eom_mode("ch0", 10000, 0.0) + + seq.enable_eom_mode("ch0", amp_on=amp, detuning_on=0.0) + assert seq.is_in_eom_mode("ch0") + + # Validation still works + with pytest.raises(ValueError, match="Invalid protocol 'smallest'"): + seq.add_eom_pulse("ch0", 1000, 0.0, protocol="smallest") + + with pytest.raises( + TypeError, match="Phase values must be a numeric value." + ): + seq.add_eom_pulse("ch0", 200, "0.") + + with pytest.raises(ValueError, match="duration has to be at least"): + seq.add_eom_pulse("ch0", 0, 0.0) + + seq.add_eom_pulse("ch0", 100, 0.0) + + seq.disable_eom_mode("ch0") + assert not seq.is_in_eom_mode("ch0") + + # Just check that building works + seq.build(amp=3.0) diff --git a/tests/test_pasqal.py b/tests/test_pasqal.py new file mode 100644 index 000000000..c13670f39 --- /dev/null +++ b/tests/test_pasqal.py @@ -0,0 +1,182 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +import pulser +import pulser_pasqal +from pulser.devices import Chadoq2 +from pulser.register import Register +from pulser.sequence import Sequence +from pulser_pasqal import Configuration, DeviceType, Endpoints, PasqalCloud +from pulser_pasqal.job_parameters import JobParameters, JobVariables + +root = Path(__file__).parent.parent + + +def test_version(): + assert pulser_pasqal.__version__ == pulser.__version__ + + +@dataclasses.dataclass +class CloudFixture: + pasqal_cloud: PasqalCloud + mock_cloud_sdk: Any + + +@pytest.fixture +def fixt(): + with patch("sdk.SDK", autospec=True) as mock_cloud_sdk_class: + pasqal_cloud_kwargs = dict( + client_id="abc", + client_secret="def", + endpoints=Endpoints(core="core_url", account="account_url"), + webhook="xyz", + ) + + pasqal_cloud = PasqalCloud(**pasqal_cloud_kwargs) + + mock_cloud_sdk_class.assert_called_once_with(**pasqal_cloud_kwargs) + + mock_cloud_sdk = mock_cloud_sdk_class.return_value + + mock_cloud_sdk_class.reset_mock() + + yield CloudFixture( + pasqal_cloud=pasqal_cloud, mock_cloud_sdk=mock_cloud_sdk + ) + + mock_cloud_sdk_class.assert_not_called() + + +test_device = Chadoq2 +virtual_device = test_device.to_virtual() + + +def check_pasqal_cloud(fixt, seq, device_type, expected_seq_representation): + create_batch_kwargs = dict( + jobs=[JobParameters(runs=10, variables=JobVariables(a=[3, 5]))], + device_type=device_type, + configuration=Configuration( + dt=0.1, + precision="normal", + extra_config=None, + ), + wait=True, + ) + + expected_create_batch_kwargs = { + **create_batch_kwargs, + "jobs": [{"runs": 10, "variables": {"qubits": None, "a": [3, 5]}}], + } + + fixt.pasqal_cloud.create_batch( + seq, + **create_batch_kwargs, + ) + + fixt.mock_cloud_sdk.create_batch.assert_called_once_with( + serialized_sequence=expected_seq_representation, + **expected_create_batch_kwargs, + ) + + get_batch_kwargs = dict( + id=10, + fetch_results=True, + ) + + fixt.pasqal_cloud.get_batch(**get_batch_kwargs) + + fixt.mock_cloud_sdk.get_batch.assert_called_once_with(**get_batch_kwargs) + + +@pytest.mark.parametrize( + "device_type, device", + [ + [device_type, device] + for device_type in (DeviceType.EMU_FREE, DeviceType.EMU_SV) + for device in (test_device, virtual_device) + ], +) +def test_pasqal_cloud_emu(fixt, device_type, device): + reg = Register(dict(enumerate([(0, 0), (0, 10)]))) + seq = Sequence(reg, device) + + check_pasqal_cloud( + fixt=fixt, + seq=seq, + device_type=device_type, + expected_seq_representation=seq.to_abstract_repr(), + ) + + +def test_pasqal_cloud_qpu(fixt): + device_type = DeviceType.QPU + device = test_device + + reg = Register(dict(enumerate([(0, 0), (0, 10)]))) + seq = Sequence(reg, device) + + check_pasqal_cloud( + fixt=fixt, + seq=seq, + device_type=device_type, + expected_seq_representation=seq.serialize(), + ) + + +def test_virtual_device_on_qpu_error(fixt): + reg = Register(dict(enumerate([(0, 0), (0, 10)]))) + device = Chadoq2.to_virtual() + seq = Sequence(reg, device) + + with pytest.raises(TypeError, match="must be a real device"): + fixt.pasqal_cloud.create_batch( + seq, + jobs=[JobParameters(runs=10, variables=JobVariables(a=[3, 5]))], + device_type=DeviceType.QPU, + configuration=Configuration( + dt=0.1, + precision="normal", + extra_config=None, + ), + wait=True, + ) + + +def test_wrong_parameters(fixt): + reg = Register(dict(enumerate([(0, 0), (0, 10)]))) + seq = Sequence(reg, test_device) + seq.declare_variable("unset", dtype=int) + + with pytest.raises( + TypeError, match="Did not receive values for variables" + ): + fixt.pasqal_cloud.create_batch( + seq, + jobs=[JobParameters(runs=10, variables=JobVariables(a=[3, 5]))], + device_type=DeviceType.QPU, + configuration=Configuration( + dt=0.1, + precision="normal", + extra_config=None, + ), + wait=True, + ) diff --git a/tests/test_pulse.py b/tests/test_pulse.py index 595ce469e..d7d53d600 100644 --- a/tests/test_pulse.py +++ b/tests/test_pulse.py @@ -18,6 +18,8 @@ import pytest from pulser import Pulse +from pulser.channels import Rydberg +from pulser.channels.eom import RydbergBeam, RydbergEOM from pulser.waveforms import BlackmanWaveform, ConstantWaveform, RampWaveform cwf = ConstantWaveform(100, -10) @@ -76,3 +78,22 @@ def test_draw(): pls_ = Pulse.ConstantDetuning(bwf, -10, 1, post_phase_shift=-np.pi) with patch("matplotlib.pyplot.show"): pls_.draw() + + +def test_fall_time(): + eom_config = RydbergEOM( + mod_bandwidth=24, + max_limiting_amp=100, + limiting_beam=RydbergBeam.RED, + intermediate_detuning=700, + controlled_beams=tuple(RydbergBeam), + ) + assert eom_config.rise_time == 20 + channel = Rydberg.Global( + None, None, mod_bandwidth=4, eom_config=eom_config + ) + assert channel.rise_time == 120 + + pulse = Pulse.ConstantPulse(1000, 1, 0, 0) + assert pulse.fall_time(channel, in_eom_mode=False) == 240 + assert pulse.fall_time(channel, in_eom_mode=True) == 40 diff --git a/tests/test_register.py b/tests/test_register.py index 67413f39d..c653ff91f 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -18,7 +18,7 @@ import pytest from pulser import Register, Register3D -from pulser.devices import Chadoq2 +from pulser.devices import Chadoq2, MockDevice def test_creation(): @@ -185,6 +185,13 @@ def test_max_connectivity(): max_atom_num, device, spacing=spacing - 1.0 ) + with pytest.raises( + NotImplementedError, + match="Maximum connectivity layouts are not well defined for a " + "device with 'min_atom_distance=0.0'.", + ): + Register.max_connectivity(1e9, MockDevice) + # Check 1 atom reg = Register.max_connectivity(1, device) assert len(reg.qubits) == 1 diff --git a/tests/test_register_layout.py b/tests/test_register_layout.py index fe9840478..efe73b9f2 100644 --- a/tests/test_register_layout.py +++ b/tests/test_register_layout.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re from hashlib import sha256 from unittest.mock import patch import numpy as np import pytest +import pulser from pulser.register import Register, Register3D from pulser.register.register_layout import RegisterLayout from pulser.register.special_layouts import ( @@ -25,16 +27,28 @@ TriangularLatticeLayout, ) -layout = RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]]) -layout3d = RegisterLayout([[0, 0, 0], [1, 1, 1], [0, 1, 0], [1, 0, 1]]) +@pytest.fixture +def layout(): + return RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]], slug="2DLayout") -def test_creation(): + +@pytest.fixture +def layout3d(): + return RegisterLayout([[0, 0, 0], [1, 1, 1], [0, 1, 0], [1, 0, 1]]) + + +def test_creation(layout, layout3d): with pytest.raises( ValueError, match="must be an array or list of coordinates" ): RegisterLayout([[0, 0, 0], [1, 1], [1, 0], [0, 1]]) + with pytest.raises( + ValueError, match="must be an array or list of coordinates" + ): + RegisterLayout([0, 1, 2]) + with pytest.raises(ValueError, match="size 2 or 3"): RegisterLayout([[0], [1], [2]]) @@ -43,13 +57,23 @@ def test_creation(): layout3d.coords == [[0, 0, 0], [0, 1, 0], [1, 0, 1], [1, 1, 1]] ) assert layout.number_of_traps == 4 - assert layout.max_atom_num == 2 assert layout.dimensionality == 2 for i, coord in enumerate(layout.coords): assert np.all(layout.traps_dict[i] == coord) + with pytest.warns(DeprecationWarning): + assert pulser.__version__ < "0.9" + assert layout.max_atom_num == layout.number_of_traps + + +def test_slug(layout, layout3d): + assert layout.slug == "2DLayout" + assert layout3d.slug is None + assert str(layout) == "2DLayout" + assert str(layout3d) == repr(layout3d) -def test_register_definition(): + +def test_register_definition(layout, layout3d): with pytest.raises(ValueError, match="must be a unique integer"): layout.define_register(0, 1, 1) @@ -62,11 +86,6 @@ def test_register_definition(): with pytest.raises(ValueError, match="must have the same size"): layout.define_register(0, 1, qubit_ids=["a", "b", "c"]) - with pytest.raises( - ValueError, match="greater than the maximum number of qubits" - ): - layout.define_register(0, 1, 3) - assert layout.define_register(0, 1) == Register.from_coordinates( [[0, 0], [0, 1]], prefix="q", center=False ) @@ -96,7 +115,7 @@ def test_register_definition(): reg2d.rotate(30) -def test_draw(): +def test_draw(layout, layout3d): with patch("matplotlib.pyplot.show"): layout.draw() @@ -107,13 +126,13 @@ def test_draw(): layout3d.draw(projection=False) -def test_repr(): +def test_repr(layout): hash_ = sha256(bytes(2)) hash_.update(layout.coords.tobytes()) assert repr(layout) == f"RegisterLayout_{hash_.hexdigest()}" -def test_eq(): +def test_eq(layout, layout3d): assert RegisterLayout([[0, 0], [1, 0]]) != Register.from_coordinates( [[0, 0], [1, 0]] ) @@ -124,7 +143,7 @@ def test_eq(): assert hash(layout1) == hash(layout2) -def test_traps_from_coordinates(): +def test_traps_from_coordinates(layout): assert layout._coords_to_traps == { (0, 0): 0, (0, 1): 1, @@ -148,15 +167,13 @@ def test_square_lattice_layout(): assert square.square_register(4) != Register.square( 4, spacing=5, prefix="q" ) - with pytest.raises( - ValueError, match="'6 x 6' array has more atoms than those available" - ): - square.square_register(6) + with pytest.raises(ValueError, match="'8x8' array doesn't fit"): + square.square_register(8) assert square.rectangular_register(3, 7, prefix="r") == Register.rectangle( 3, 7, spacing=5, prefix="r" ) - with pytest.raises(ValueError, match="'10 x 3' array doesn't fit"): + with pytest.raises(ValueError, match="'10x3' array doesn't fit"): square.rectangular_register(10, 3) @@ -167,28 +184,34 @@ def test_triangular_lattice_layout(): assert tri.hexagonal_register(19) == Register.hexagon( 2, spacing=5, prefix="q" ) - with pytest.raises(ValueError, match="hold at most 25 atoms, not '26'"): - tri.hexagonal_register(26) + with pytest.raises( + ValueError, + match=re.escape( + "The desired register has more atoms (51) than there" + " are traps in this TriangularLatticeLayout (50)" + ), + ): + tri.hexagonal_register(51) with pytest.raises( - ValueError, match="has more atoms than those available" + ValueError, match="has more atoms than there are traps" ): - tri.rectangular_register(7, 4) + tri.rectangular_register(7, 8) # Case where the register doesn't fit with pytest.raises(ValueError, match="not a part of the RegisterLayout"): tri.rectangular_register(8, 3) # But this fits fine, though off-centered with the Register default - tri.rectangular_register(5, 5) != Register.triangular_lattice( + assert tri.rectangular_register(5, 5) != Register.triangular_lattice( 5, 5, spacing=5, prefix="q" ) def test_mappable_register_creation(): tri = TriangularLatticeLayout(50, 5) - with pytest.raises(ValueError, match="greater than the maximum"): - tri.make_mappable_register(26) + with pytest.raises(ValueError, match="greater than the number of traps"): + tri.make_mappable_register(51) mapp_reg = tri.make_mappable_register(5) assert mapp_reg.qubit_ids == ("q0", "q1", "q2", "q3", "q4") diff --git a/tests/test_sequence.py b/tests/test_sequence.py index e466b0e53..2fe2e0b22 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import dataclasses import json from typing import Any from unittest.mock import patch @@ -21,10 +22,11 @@ import pulser from pulser import Pulse, Register, Register3D, Sequence from pulser.channels import Raman, Rydberg -from pulser.devices import Chadoq2, MockDevice -from pulser.devices._device_datacls import Device +from pulser.devices import Chadoq2, IroiseMVP, MockDevice +from pulser.devices._device_datacls import Device, VirtualDevice from pulser.register.mappable_reg import MappableRegister from pulser.register.special_layouts import TriangularLatticeLayout +from pulser.sampler import sample from pulser.sequence.sequence import _TimeSlot from pulser.waveforms import ( BlackmanWaveform, @@ -38,13 +40,9 @@ def test_init(): - with pytest.raises(TypeError, match="must be of type 'Device'"): + with pytest.raises(TypeError, match="must be of type 'BaseDevice'"): Sequence(reg, Device) - fake_device = Device("fake", 2, 70, 100, 100, 1, Chadoq2._channels) - with pytest.warns(UserWarning, match="imported from 'pulser.devices'"): - Sequence(reg, fake_device) - seq = Sequence(reg, device) assert seq.qubit_info == reg.qubits assert seq.declared_channels == {} @@ -141,6 +139,329 @@ def test_magnetic_field(): assert np.all(seq3_.magnetic_field == np.array((1.0, 0.0, 0.0))) +@pytest.fixture +def devices(): + + device1 = Device( + name="test_device1", + dimensions=2, + rydberg_level=70, + max_atom_num=100, + max_radial_distance=60, + min_atom_distance=5, + _channels=( + ( + "raman_global", + Raman.Global( + 2 * np.pi * 20, + 2 * np.pi * 10, + max_duration=2**26, + ), + ), + ( + "raman_local", + Raman.Local( + 2 * np.pi * 20, + 2 * np.pi * 10, + clock_period=1, + max_duration=2**26, + max_targets=3, + mod_bandwidth=4, + ), + ), + ( + "rydberg_global", + Rydberg.Global( + max_abs_detuning=2 * np.pi * 4, + max_amp=2 * np.pi * 3, + clock_period=4, + max_duration=2**26, + ), + ), + ), + ) + + device2 = Device( + name="test_device2", + dimensions=2, + rydberg_level=70, + max_atom_num=100, + max_radial_distance=60, + min_atom_distance=5, + _channels=( + ( + "rmn_local", + Raman.Local( + 2 * np.pi * 20, + 2 * np.pi * 10, + clock_period=3, + max_duration=2**26, + max_targets=5, + mod_bandwidth=2, + fixed_retarget_t=2, + ), + ), + ( + "rydberg_global", + Rydberg.Global( + max_abs_detuning=2 * np.pi * 4, + max_amp=2 * np.pi * 3, + clock_period=2, + max_duration=2**26, + ), + ), + ), + ) + + device3 = VirtualDevice( + name="test_device3", + dimensions=2, + rydberg_level=70, + min_atom_distance=5, + _channels=( + ( + "rmn_local1", + Raman.Local( + max_abs_detuning=2 * np.pi * 20, + max_amp=2 * np.pi * 10, + min_retarget_interval=220, + fixed_retarget_t=1, + max_targets=1, + mod_bandwidth=2, + clock_period=3, + min_duration=16, + max_duration=2**26, + ), + ), + ( + "rmn_local2", + Raman.Local( + 2 * np.pi * 20, + 2 * np.pi * 10, + clock_period=3, + max_duration=2**26, + mod_bandwidth=2, + fixed_retarget_t=2, + ), + ), + ( + "rydberg_global", + Rydberg.Global( + max_abs_detuning=2 * np.pi * 4, + max_amp=2 * np.pi * 3, + clock_period=4, + max_duration=2**26, + ), + ), + ), + ) + + return [device1, device2, device3] + + +@pytest.fixture +def pulses(): + + rise = Pulse.ConstantDetuning( + RampWaveform(252, 0.0, 2.3 * 2 * np.pi), + -4 * np.pi, + 0.0, + ) + sweep = Pulse.ConstantAmplitude( + 2.3 * 2 * np.pi, + RampWaveform(400, -4 * np.pi, 4 * np.pi), + 1.0, + ) + fall = Pulse.ConstantDetuning( + RampWaveform(500, 2.3 * 2 * np.pi, 0.0), + 4 * np.pi, + 0.0, + ) + return [rise, sweep, fall] + + +def init_seq( + device, channel_name, channel_id, l_pulses, initial_target=None +) -> Sequence: + seq = Sequence(reg, device) + seq.declare_channel( + channel_name, channel_id, initial_target=initial_target + ) + if l_pulses is not None: + for pulse in l_pulses: + seq.add(pulse, channel_name) + return seq + + +def test_switch_device_down(devices, pulses): + + # Device checkout + seq = init_seq(Chadoq2, "ising", "rydberg_global", None) + with pytest.warns( + UserWarning, + match="Switching a sequence to the same device" + + " returns the sequence unchanged.", + ): + seq.switch_device(Chadoq2) + + # From sequence reusing channels to Device without reusable channels + seq = init_seq(MockDevice, "global", "rydberg_global", None) + seq.declare_channel("global2", "rydberg_global") + with pytest.raises( + TypeError, + match="No match for channel global2 with the" + " right basis and addressing.", + ): + # Can't find a match for the 2nd rydberg_global + seq.switch_device(Chadoq2) + + seq = init_seq(MockDevice, "ising", "rydberg_global", None) + mod_mock = dataclasses.replace(MockDevice, rydberg_level=50) + with pytest.raises( + ValueError, + match="Strict device match failed because the devices" + + " have different Rydberg levels.", + ): + seq.switch_device(mod_mock, True) + + with pytest.warns( + UserWarning, + match="Switching to a device with a different Rydberg level," + " check that the expected Rydberg interactions still hold.", + ): + seq.switch_device(mod_mock, False) + + seq = init_seq(devices[0], "ising", "raman_global", None) + for dev_ in ( + Chadoq2, # Different Channels basis + devices[1], # Different addressing channels + ): + with pytest.raises( + TypeError, + match="No match for channel ising with the" + + " right basis and addressing.", + ): + seq.switch_device(dev_) + + # Clock_period not match + seq = init_seq( + devices[0], + channel_name="ising", + channel_id="rydberg_global", + l_pulses=pulses[:2], + ) + with pytest.raises( + ValueError, + match="No match for channel ising with the same clock_period.", + ): + seq.switch_device(devices[1], True) + + seq = init_seq( + devices[2], + channel_name="digital", + channel_id="rmn_local1", + l_pulses=[], + initial_target=["q0"], + ) + with pytest.raises( + ValueError, + match="No match for channel digital with the same mod_bandwidth.", + ): + seq.switch_device(devices[0], True) + + with pytest.raises( + ValueError, + match="No match for channel digital" + + " with the same fixed_retarget_t.", + ): + seq.switch_device(devices[1], True) + + +@pytest.mark.parametrize("device_ind, strict", [(1, False), (2, True)]) +def test_switch_device_up(device_ind, devices, pulses, strict): + + # Device checkout + seq = init_seq(Chadoq2, "ising", "rydberg_global", None) + assert seq.switch_device(Chadoq2)._device == Chadoq2 + + # Test non-strict mode + assert "ising" in seq.switch_device(devices[0]).declared_channels + + # Strict: Jump_phase_time & CLock-period criteria + # Jump_phase_time check 1: phase not nill + seq1 = init_seq( + devices[device_ind], + channel_name="ising", + channel_id="rydberg_global", + l_pulses=pulses[:2], + ) + seq2 = init_seq( + devices[0], + channel_name="ising", + channel_id="rydberg_global", + l_pulses=pulses[:2], + ) + new_seq = seq1.switch_device(devices[0], strict) + s1 = sample(new_seq) + s2 = sample(seq1) + s3 = sample(seq2) + nested_s1 = s1.to_nested_dict()["Global"]["ground-rydberg"] + nested_s2 = s2.to_nested_dict()["Global"]["ground-rydberg"] + nested_s3 = s3.to_nested_dict()["Global"]["ground-rydberg"] + + # Check if the samples are the same + for key in ["amp", "det", "phase"]: + np.testing.assert_array_equal(nested_s1[key], nested_s3[key]) + if strict: + np.testing.assert_array_equal(nested_s1[key], nested_s2[key]) + + # Channels with the same mod_bandwidth and fixed_retarget_t + seq = init_seq( + devices[2], + channel_name="digital", + channel_id="rmn_local2", + l_pulses=[], + initial_target=["q0"], + ) + assert seq.switch_device(devices[1], True)._device == devices[1] + assert "digital" in seq.switch_device(devices[1], True).declared_channels + + +def test_switch_device_eom(): + # Sequence with EOM blocks + seq = init_seq(IroiseMVP, "rydberg", "rydberg_global", []) + seq.enable_eom_mode("rydberg", amp_on=2.0, detuning_on=0.0) + seq.add_eom_pulse("rydberg", 100, 0.0) + seq.delay(200, "rydberg") + assert seq._schedule["rydberg"].eom_blocks + + err_base = "No match for channel rydberg " + with pytest.raises( + TypeError, match=err_base + "with an EOM configuration." + ): + seq.switch_device(Chadoq2) + + ch_obj = seq.declared_channels["rydberg"] + mod_eom_config = dataclasses.replace( + ch_obj.eom_config, max_limiting_amp=10 * 2 * np.pi + ) + mod_ch_obj = dataclasses.replace(ch_obj, eom_config=mod_eom_config) + mod_iroise = dataclasses.replace( + IroiseMVP, _channels=(("rydberg_global", mod_ch_obj),) + ) + with pytest.raises( + ValueError, match=err_base + "with the same EOM configuration." + ): + seq.switch_device(mod_iroise, strict=True) + + mod_seq = seq.switch_device(mod_iroise, strict=False) + og_eom_block = seq._schedule["rydberg"].eom_blocks[0] + mod_eom_block = mod_seq._schedule["rydberg"].eom_blocks[0] + assert og_eom_block.detuning_on == mod_eom_block.detuning_on + assert og_eom_block.rabi_freq == mod_eom_block.rabi_freq + assert og_eom_block.detuning_off != mod_eom_block.detuning_off + + def test_target(): seq = Sequence(reg, device) seq.declare_channel("ch0", "raman_local", initial_target="q1") @@ -188,6 +509,11 @@ def test_target(): seq2 = Sequence(reg, MockDevice) seq2.declare_channel("ch0", "raman_local", initial_target={"q1", "q10"}) + + # Test unlimited targets with Local channel when 'max_targets=None' + assert seq2.declared_channels["ch0"].max_targets is None + seq2.target(set(reg.qubit_ids) - {"q2"}, "ch0") + seq2.phase_shift(1, "q2") with pytest.raises(ValueError, match="qubits with different phase"): seq2.target({"q3", "q1", "q2"}, "ch0") @@ -317,21 +643,54 @@ def test_block_if_measured(call, args): getattr(seq, call)(*args) -def test_str(): - seq = Sequence(reg, device) +def test_str(mod_device): + seq = Sequence(reg, mod_device) seq.declare_channel("ch0", "raman_local", initial_target="q0") pulse = Pulse.ConstantPulse(500, 2, -10, 0, post_phase_shift=np.pi) seq.add(pulse, "ch0") - seq.delay(200, "ch0") + seq.delay(300, "ch0") seq.target("q7", "ch0") + + seq.declare_channel("ch1", "rydberg_global") + seq.enable_eom_mode("ch1", 2, 0, optimal_detuning_off=10.0) + seq.add_eom_pulse("ch1", duration=100, phase=0, protocol="no-delay") + seq.delay(500, "ch1") + seq.measure("digital") - msg = ( + msg_ch0 = ( "Channel: ch0\nt: 0 | Initial targets: q0 | Phase Reference: 0.0 " + "\nt: 0->500 | Pulse(Amp=2 rad/µs, Detuning=-10 rad/µs, Phase=0) " - + "| Targets: q0\nt: 500->700 | Delay \nt: 700->700 | Target: q7 | " - + "Phase Reference: 0.0\n\nMeasured in basis: digital" + + "| Targets: q0\nt: 500->800 | Delay \nt: 800->800 | Target: q7 | " + + "Phase Reference: 0.0" + ) + targets = ", ".join(sorted(reg.qubit_ids)) + msg_ch1 = ( + f"\n\nChannel: ch1\nt: 0 | Initial targets: {targets} " + "| Phase Reference: 0.0 " + "\nt: 0->100 | Pulse(Amp=2 rad/µs, Detuning=0 rad/µs, Phase=0) " + f"| Targets: {targets}" + "\nt: 100->600 | EOM Delay | Detuning: -1 rad/µs" + ) + + measure_msg = "\n\nMeasured in basis: digital" + print(seq) + assert seq.__str__() == msg_ch0 + msg_ch1 + measure_msg + + seq2 = Sequence(Register({"q0": (0, 0), 1: (5, 5)}), device) + seq2.declare_channel("ch1", "rydberg_global") + with pytest.raises( + NotImplementedError, + match="Can't print sequence with qubit IDs of different types.", + ): + str(seq2) + + # Check qubit IDs are sorted + seq3 = Sequence(Register({"q1": (0, 0), "q0": (5, 5)}), device) + seq3.declare_channel("ch2", "rydberg_global") + assert str(seq3) == ( + "Channel: ch2\n" + "t: 0 | Initial targets: q0, q1 | Phase Reference: 0.0 \n\n" ) - assert seq.__str__() == msg def test_sequence(): @@ -439,6 +798,10 @@ def test_config_slm_mask(): reg_s = Register({"q0": (0, 0), "q1": (10, 10), "q2": (-10, -10)}) seq_s = Sequence(reg_s, device) + with pytest.raises(ValueError, match="does not have an SLM mask."): + seq_ = Sequence(reg_s, IroiseMVP) + seq_.config_slm_mask(["q0"]) + with pytest.raises(TypeError, match="must be castable to set"): seq_s.config_slm_mask(0) with pytest.raises(TypeError, match="must be castable to set"): @@ -584,15 +947,17 @@ def test_hardware_constraints(): rydberg_global = Rydberg.Global( 2 * np.pi * 20, 2 * np.pi * 2.5, - phase_jump_time=120, # ns + clock_period=4, mod_bandwidth=4, # MHz ) raman_local = Raman.Local( 2 * np.pi * 20, 2 * np.pi * 10, - phase_jump_time=120, # ns + min_retarget_interval=220, fixed_retarget_t=200, # ns + max_targets=1, + clock_period=4, mod_bandwidth=7, # MHz ) @@ -608,10 +973,8 @@ def test_hardware_constraints(): ("raman_local", raman_local), ), ) - with pytest.warns( - UserWarning, match="should be imported from 'pulser.devices'" - ): - seq = Sequence(reg, ConstrainedChadoq2) + + seq = Sequence(reg, ConstrainedChadoq2) seq.declare_channel("ch0", "rydberg_global") seq.declare_channel("ch1", "raman_local", initial_target="q1") @@ -651,18 +1014,24 @@ def test_hardware_constraints(): mid_delay = 40 seq.delay(mid_delay, "ch0") seq.add(const_pls, "ch0") # Phase = π - assert seq._last("ch0").ti - tf_ == rydberg_global.phase_jump_time + interval = seq._schedule["ch0"].adjust_duration( + rydberg_global.phase_jump_time + black_pls.fall_time(rydberg_global) + ) + assert seq._schedule["ch0"][-1].ti - tf_ == interval added_delay_slot = seq._schedule["ch0"][-2] assert added_delay_slot.type == "delay" - assert ( - added_delay_slot.tf - added_delay_slot.ti - == rydberg_global.phase_jump_time - mid_delay - ) + assert added_delay_slot.tf - added_delay_slot.ti == interval - mid_delay + + # Check that there is no phase jump buffer with 'no-delay' + seq.add(black_pls, "ch0", protocol="no-delay") # Phase = 0 + assert seq._schedule["ch0"][-1].ti == seq._schedule["ch0"][-2].tf tf_ = seq.get_duration("ch0") seq.align("ch0", "ch1") - fall_time = const_pls.fall_time(rydberg_global) - assert seq.get_duration() == tf_ + fall_time + fall_time = black_pls.fall_time(rydberg_global) + assert seq.get_duration() == seq._schedule["ch0"].adjust_duration( + tf_ + fall_time + ) with pytest.raises(ValueError, match="'mode' must be one of"): seq.draw(mode="all") @@ -896,3 +1265,81 @@ def test_multiple_index_targets(): seq.target_index(var_array + 1, channel="ch0") built_seq = seq.build(var_array=[1, 2]) assert built_seq._last("ch0").targets == {"q2", "q3"} + + +def test_eom_mode(mod_device): + seq = Sequence(reg, mod_device) + seq.declare_channel("ch0", "rydberg_global") + ch0_obj = seq.declared_channels["ch0"] + assert not seq.is_in_eom_mode("ch0") + + amp_on = 1.0 + detuning_on = 0.0 + seq.enable_eom_mode("ch0", amp_on, detuning_on, optimal_detuning_off=-100) + assert seq.is_in_eom_mode("ch0") + + delay_duration = 200 + seq.delay(delay_duration, "ch0") + detuning_off = seq._schedule["ch0"].eom_blocks[-1].detuning_off + assert detuning_off != 0 + + with pytest.raises(RuntimeError, match="There is no slot with a pulse."): + # The EOM delay slot (which is a pulse slot) is ignored + seq._schedule["ch0"].last_pulse_slot() + + delay_slot = seq._schedule["ch0"][-1] + assert seq._schedule["ch0"].is_eom_delay(delay_slot) + assert delay_slot.ti == 0 + assert delay_slot.tf == delay_duration + assert delay_slot.type == Pulse.ConstantPulse( + delay_duration, 0.0, detuning_off, 0.0 + ) + + assert seq._schedule["ch0"].get_eom_mode_intervals() == [ + (0, delay_slot.tf) + ] + + pulse_duration = 100 + seq.add_eom_pulse("ch0", pulse_duration, phase=0.0) + first_pulse_slot = seq._schedule["ch0"].last_pulse_slot() + assert not seq._schedule["ch0"].is_eom_delay(first_pulse_slot) + assert first_pulse_slot.ti == delay_slot.tf + assert first_pulse_slot.tf == first_pulse_slot.ti + pulse_duration + eom_pulse = Pulse.ConstantPulse(pulse_duration, amp_on, detuning_on, 0.0) + assert first_pulse_slot.type == eom_pulse + + # Check phase jump buffer + seq.add_eom_pulse("ch0", pulse_duration, phase=np.pi) + second_pulse_slot = seq._schedule["ch0"].last_pulse_slot() + phase_buffer = ( + eom_pulse.fall_time(ch0_obj, in_eom_mode=True) + + seq.declared_channels["ch0"].phase_jump_time + ) + assert second_pulse_slot.ti == first_pulse_slot.tf + phase_buffer + + # Check phase jump buffer is not enforced with "no-delay" + seq.add_eom_pulse("ch0", pulse_duration, phase=0.0, protocol="no-delay") + last_pulse_slot = seq._schedule["ch0"].last_pulse_slot() + assert last_pulse_slot.ti == second_pulse_slot.tf + + eom_intervals = seq._schedule["ch0"].get_eom_mode_intervals() + assert eom_intervals == [(0, last_pulse_slot.tf)] + + with pytest.raises( + RuntimeError, match="The chosen channel is in EOM mode" + ): + seq.add(eom_pulse, "ch0") + + assert seq.get_duration() == last_pulse_slot.tf + assert seq.get_duration(include_fall_time=True) == ( + last_pulse_slot.tf + eom_pulse.fall_time(ch0_obj, in_eom_mode=True) + ) + + seq.disable_eom_mode("ch0") + assert not seq.is_in_eom_mode("ch0") + # Check the EOM interval did not change + assert seq._schedule["ch0"].get_eom_mode_intervals() == eom_intervals + buffer_delay = seq._schedule["ch0"][-1] + assert buffer_delay.ti == last_pulse_slot.tf + assert buffer_delay.tf == buffer_delay.ti + eom_pulse.fall_time(ch0_obj) + assert buffer_delay.type == "delay" diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index 4ff9c36d9..afa8ae0fb 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -64,6 +64,16 @@ def assert_nested_dict_equality(got: dict, want: dict) -> None: # Tests +def test_init_error(seq_rydberg): + var = seq_rydberg.declare_variable("var") + seq_rydberg.delay(var, "ch0") + assert seq_rydberg.is_parametrized() + with pytest.raises( + NotImplementedError, match="Parametrized sequences can't be sampled." + ): + sample(seq_rydberg) + + def test_one_pulse_sampling(): """Test the sample function on a one-pulse sequence.""" reg = pulser.Register.square(1, prefix="q") @@ -156,19 +166,18 @@ def test_modulation_local(mod_device): assert output_samples.max_duration == seq.get_duration( include_fall_time=True ) + out_ch_samples = output_samples.channel_samples["ch0"] + # The target slots account for fall time in both cases + assert input_samples.channel_samples["ch0"].slots == out_ch_samples.slots # Check that the target slots account for fall time - in_ch_samples = input_samples.channel_samples["ch0"] - out_ch_samples = output_samples.channel_samples["ch0"] - expected_slots = deepcopy(in_ch_samples.slots) + out_slots = out_ch_samples.slots # The first slot should extend to the second - expected_slots[0].tf += partial_fall - assert expected_slots[0].tf == expected_slots[1].ti + assert out_slots[0].tf == pulse1.duration + partial_fall + assert out_slots[0].tf == out_slots[1].ti # The next slots should fully account for fall time - expected_slots[1].tf += pulse2.fall_time(ch_obj) - expected_slots[2].tf += pulse1.fall_time(ch_obj) - - assert out_ch_samples.slots == expected_slots + for slot, pulse in zip(out_slots[1:], (pulse2, pulse1)): + assert slot.tf - slot.ti == pulse.duration + pulse.fall_time(ch_obj) # Check that the samples are fully extracted to the nested dict samples_dict = output_samples.to_nested_dict() @@ -179,6 +188,46 @@ def test_modulation_local(mod_device): np.testing.assert_array_equal(getattr(out_ch_samples, qty), combined) +def test_eom_modulation(mod_device): + seq = pulser.Sequence(pulser.Register.square(2), mod_device) + seq.declare_channel("ch0", "rydberg_global") + seq.enable_eom_mode("ch0", amp_on=1, detuning_on=0.0) + seq.add_eom_pulse("ch0", 100, 0.0) + seq.delay(200, "ch0") + seq.add_eom_pulse("ch0", 100, 0.0) + end_of_eom = seq.get_duration() + seq.disable_eom_mode("ch0") + seq.add(Pulse.ConstantPulse(500, 1, 0, 0), "ch0") + + full_duration = seq.get_duration(include_fall_time=True) + eom_mask = np.zeros(full_duration, dtype=bool) + eom_mask[:end_of_eom] = True + + input_samples = sample( + seq, extended_duration=full_duration + ).channel_samples["ch0"] + mod_samples = sample(seq, modulation=True, extended_duration=full_duration) + chan = seq.declared_channels["ch0"] + for qty in ("amp", "det"): + samples = getattr(input_samples, qty) + eom_input = samples.copy() + eom_input[~eom_mask] = 0.0 + eom_output = chan.modulate(eom_input, eom=True)[:full_duration] + aom_input = samples.copy() + aom_input[eom_mask] = 0.0 + aom_output = chan.modulate(aom_input, eom=False)[:full_duration] + np.testing.assert_array_equal(eom_input + aom_input, samples) + + want = eom_output + aom_output + + # Check that modulation through sample() = sample() + modulation + got = getattr(mod_samples.channel_samples["ch0"], qty) + alt_got = getattr(input_samples.modulate(chan, full_duration), qty) + np.testing.assert_array_equal(got, alt_got) + + np.testing.assert_array_equal(want, got) + + @pytest.fixture def seq_with_SLM() -> pulser.Sequence: q_dict = { diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 2d950dd90..23c8a044e 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -968,12 +968,14 @@ def test_simulation_with_modulation(mod_device, reg): ("control1", slice(mod_dt, 2 * mod_dt)), ]: np.testing.assert_allclose( - raman_samples[qid]["amp"][time_slice], pulse1_mod_samples + raman_samples[qid]["amp"][time_slice], + pulse1_mod_samples, + atol=1e-2, ) np.testing.assert_equal( raman_samples[qid]["det"][time_slice], sim._doppler_detune[qid] ) - np.testing.assert_equal( + np.testing.assert_allclose( raman_samples[qid]["phase"][time_slice], pulse1.phase ) @@ -996,7 +998,7 @@ def pos_factor(qid): np.testing.assert_equal( rydberg_samples[qid]["det"][time_slice], sim._doppler_detune[qid] ) - np.testing.assert_equal( + np.testing.assert_allclose( rydberg_samples[qid]["phase"][time_slice], pulse1.phase ) diff --git a/tests/test_waveforms.py b/tests/test_waveforms.py index 3c140ddb3..092cdfb65 100644 --- a/tests/test_waveforms.py +++ b/tests/test_waveforms.py @@ -102,7 +102,6 @@ def test_draw(): rydberg_global = Rydberg.Global( 2 * np.pi * 20, 2 * np.pi * 2.5, - phase_jump_time=120, # ns mod_bandwidth=4, # MHz ) with patch("matplotlib.pyplot.show"): @@ -418,7 +417,6 @@ def test_modulation(): rydberg_global = Rydberg.Global( 2 * np.pi * 20, 2 * np.pi * 2.5, - phase_jump_time=120, # ns mod_bandwidth=4, # MHz ) mod_samples = constant.modulated_samples(rydberg_global) diff --git a/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb b/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb new file mode 100644 index 000000000..0a74f22c8 --- /dev/null +++ b/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb @@ -0,0 +1,317 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Output Modulation & EOM Mode" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from pulser import Sequence, Register, Pulse\n", + "from pulser.devices import VirtualDevice\n", + "from pulser.channels import Rydberg, Raman" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Output Modulation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Modulation Bandwidth" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When programming physical devices, you will likely come across the notion of *modulation bandwidth*. When a channel has a finite modulation bandwidth, its output (what actually comes out of the channel) is modulated when compared to its input (what is programmed in Pulser) because the component takes some amount of time to reach the desired value. \n", + "\n", + "To illustrate this, let us start by creating a channel with a defined modulation bandwidth." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rydberg_ch = Rydberg.Global(\n", + " max_abs_detuning=20 * 2 * np.pi,\n", + " max_amp=10 * 2 * np.pi,\n", + " mod_bandwidth=5, # MHz\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With this channel object, we can check what the modulation of a waveform will look like. Let's take, for instance, a short square waveform:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.waveforms import ConstantWaveform\n", + "\n", + "constant_wf = ConstantWaveform(duration=100, value=1)\n", + "constant_wf.draw(output_channel=rydberg_ch)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We observe two things:\n", + " \n", + " 1. The output is streched when compared with the input. This is always the case, and we refer to the time it takes the output to ramp up as the `rise time`. \n", + " 2. The output does not have enough time to reach the maximum value set in the input. This happens only when the input pulse is too short.\n", + "\n", + "If we make the pulse long enough, we will see that it will still be extended on both sides by the rise time, but now it reaches the maximum value:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "constant_wf2 = ConstantWaveform(duration=300, value=1)\n", + "constant_wf2.draw(output_channel=rydberg_ch)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note also that all inputs are modulated, but the effect is most pronounced in square pulses. If we take, for example, a `BlackmanWaveform` of similar duration, we see the difference between input and output is more subtle (on the other hand, the output never gets a chance to reach the maximum value because the input is not held at the maximum value)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.waveforms import BlackmanWaveform\n", + "\n", + "blackman_wf = BlackmanWaveform(300, 0.13)\n", + "blackman_wf.draw(output_channel=rydberg_ch)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Influence in a Sequence" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When creating a sequence on a device whose channels have a finite modulation bandwitdh, its effects are manifested in multiple ways. Let us start by creating such a device and making a simple pulse sequence with it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "raman_ch = Raman.Local(\n", + " max_abs_detuning=0,\n", + " max_amp=20 * 2 * np.pi,\n", + " fixed_retarget_t=50,\n", + " mod_bandwidth=4,\n", + ")\n", + "\n", + "test_device = VirtualDevice(\n", + " name=\"test_device\",\n", + " dimensions=2,\n", + " rydberg_level=60,\n", + " _channels=(\n", + " (\"rydberg_global\", rydberg_ch),\n", + " (\"raman_local\", raman_ch),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "seq = Sequence(Register.square(2, prefix=\"q\"), test_device)\n", + "\n", + "seq.declare_channel(\"raman\", \"raman_local\", initial_target=\"q0\")\n", + "seq.declare_channel(\"rydberg\", \"rydberg_global\")\n", + "\n", + "seq.add(Pulse.ConstantDetuning(blackman_wf, -5, 0), \"rydberg\")\n", + "\n", + "short_pulse = Pulse.ConstantPulse(100, 1, 0, 0)\n", + "seq.add(short_pulse, \"raman\")\n", + "seq.target(\"q1\", \"raman\")\n", + "seq.add(short_pulse, \"raman\")\n", + "seq.delay(100, \"raman\")\n", + "long_pulse = Pulse.ConstantPulse(500, 1, 0, 0)\n", + "seq.add(long_pulse, \"raman\")\n", + "\n", + "seq.add(Pulse.ConstantDetuning(blackman_wf, 5, np.pi), \"rydberg\")\n", + "\n", + "seq.draw(draw_phase_curve=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, `Sequence.draw()` will display both the programmed input and the modulated output. In this way, one can compare how the output will change with respect to the intended input.\n", + "\n", + "From looking at the output, there are multiple things to note:\n", + "\n", + "1. Not only the amplitude but also the detuning and phase are modulated, all with the same modulation bandwidth.\n", + "2. Alignment between channels takes into account the extended duration of the pulses in the other channels. Note, for instance, how the last pulse on the `rydberg` channel starts only after the output of the `raman` channel goes to zero.\n", + "3. Similarly, changing the target in a local channel will also wait for the output to ramp down before starting the retargeting.\n", + "4. For consecutive pulses in the same channel, there is no automatically imposed delay between them to allow one pulse to finish before the next one starts. As such, whenever the interval between two pulses is too short, they will be \"merged\" together, as is illustrated in the `raman` channel." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Usage in Simulation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to get the most realistic results when simulating a sequence, it may be valuable to use the expected output rather than the programmed input. To do so, one can simply initialize the `Simulation` class with `with_modulation=True`.\n", + "Below, we simulate the sequence with and without modulation to assess the effect it has on the overlap between the resulting final states." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser_simulation import Simulation\n", + "\n", + "sim_in = Simulation(seq)\n", + "sim_out = Simulation(seq, with_modulation=True)\n", + "\n", + "input_final_state = sim_in.run().get_final_state()\n", + "output_final_state = sim_out.run().get_final_state()\n", + "\n", + "print(\"Final state overlap:\", input_final_state.overlap(output_final_state))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## EOM Mode Operation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The modulation bandwidth of a channel can impose significant limitations on how a pulse sequence is programmed. Perhaps most importantly, it can force the user to program longer pulses than would otherwise be required, resulting in longer sequences and consequently noisier results.\n", + "\n", + "To overcome these limitations, a channel can be equipped with an EOM that allows the execution of pulses with a higher modulation bandwidth. For now, EOM mode operation is reserved for `Rydberg` channels and works under very specific conditions:\n", + "\n", + " 1. EOM mode must be explicitly enabled (`Sequence.enable_eom_mode()`) and disabled (`Sequence.disable_eom_mode()`).\n", + " 2. A buffering time is automatically added before the EOM mode is enabled and after it is disabled, as it needs to be isolated from regular channel operation.\n", + " 3. When enabling the EOM mode, one must choose the amplitude and detuning value that all square pulses will have. These values will also determine a set of options for the detuning during delays, out of which one is value chosen.\n", + " 4. While in EOM mode, one can only add delays or pulses of variable duration (through `Sequence.add_eom_pulse()`) – changing the phase between pulses is also allowed, but the necessary buffer time for a phase jump will still be enforced." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let us showcase these features with the `IroiseMVP` device, which features an EOM on its `rydberg_global` channel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.devices import IroiseMVP\n", + "\n", + "seq = Sequence(Register.square(2, spacing=6), IroiseMVP)\n", + "seq.declare_channel(\"rydberg\", \"rydberg_global\")\n", + "\n", + "seq.add(Pulse.ConstantPulse(100, 1, 0, 0), \"rydberg\")\n", + "seq.enable_eom_mode(\"rydberg\", amp_on=1.0, detuning_on=0.0)\n", + "seq.add_eom_pulse(\"rydberg\", duration=100, phase=0.0)\n", + "seq.delay(200, \"rydberg\")\n", + "seq.add_eom_pulse(\"rydberg\", duration=60, phase=0.0)\n", + "seq.disable_eom_mode(\"rydberg\")\n", + "seq.add(Pulse.ConstantPulse(100, 1, 0, 0), \"rydberg\")\n", + "\n", + "seq.draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(seq)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As expected, inside the isolated EOM mode block in the middle we see that the pulses are much sharper, but we can only do square pulses with a fixed amplitude and there is some non-zero detuning in between them." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tutorials/advanced_features/Register Layouts.ipynb b/tutorials/advanced_features/Register Layouts.ipynb new file mode 100644 index 000000000..59ddcb643 --- /dev/null +++ b/tutorials/advanced_features/Register Layouts.ipynb @@ -0,0 +1,570 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Register Layouts & Mappable Registers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from pulser.register.register_layout import RegisterLayout\n", + "from pulser import Sequence, Pulse" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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. \n", + "\n", + "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.\n", + "\n", + "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." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Arbitrary Layouts" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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. \n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Generating random coordinates\n", + "np.random.seed(301122) # Keeps results consistent between runs\n", + "traps = np.random.randint(0, 30, size=(20, 2))\n", + "traps = traps - np.mean(traps, axis=0)\n", + "\n", + "# Creating the layout\n", + "layout = RegisterLayout(traps, slug=\"random_20\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "layout.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Useful properties" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To access the trap coordinates:\n", + "- `RegisterLayout.traps_dict` gives a mapping between trap IDs and coordinates\n", + "- `RegisterLayout.coords` provides the ordered list of trap coordinates" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To identify a layout, one can use its `repr()` for a unique identifier or its `str()` for the `slug` (if specified)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"The layout slug:\", layout)\n", + "print(\"The unique ID layout:\", repr(layout))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Register definition" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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: " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**1. Defined by the trap IDs:**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trap_ids = [4, 8, 19, 0]\n", + "reg1 = layout.define_register(*trap_ids, qubit_ids=[\"a\", \"b\", \"c\", \"d\"])\n", + "reg1.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the information of the layout is stored internally in the Register:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "reg1.layout" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**2. Defined from the trap coordinates:**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "some_coords = layout.coords[\n", + " np.random.choice(np.arange(layout.number_of_traps), size=10, replace=False)\n", + "]\n", + "trap_ids = layout.get_traps_from_coordinates(*some_coords)\n", + "reg2 = layout.define_register(*trap_ids)\n", + "reg2.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Special Layouts" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.register.special_layouts import (\n", + " SquareLatticeLayout,\n", + " TriangularLatticeLayout,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `SquareLatticeLayout`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`SquareLatticeLayout` specifies a layout from an underlying square lattice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "square_layout = SquareLatticeLayout(7, 4, spacing=5)\n", + "print(square_layout)\n", + "square_layout.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With `SquareLatticeLayout.rectangular_register()` and `SquareLatticeLayout.square_register()`, one can conveniently define a new `Register`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "square_layout.rectangular_register(rows=3, columns=4, prefix=\"a\").draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "square_layout.square_register(side=3).draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `TriangularLatticeLayout`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`TriangularLatticeLayout` specifies a layout from an underlying triangular lattice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "tri_layout = TriangularLatticeLayout(n_traps=100, spacing=5)\n", + "print(tri_layout)\n", + "tri_layout.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With `TriangularLatticeLayout.hexagonal_register()` or `TriangularLatticeLayout.rectangular_register()`, one can easily define a `Register` from a subset of existing traps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tri_layout.hexagonal_register(n_atoms=50).draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "tri_layout.rectangular_register(rows=3, atoms_per_row=7).draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Devices with pre-calibrated layouts" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.devices import Device\n", + "from pulser.channels import Rydberg, Raman\n", + "\n", + "TestDevice = Device(\n", + " name=\"TestDevice\",\n", + " dimensions=2,\n", + " rydberg_level=70,\n", + " max_atom_num=100,\n", + " max_radial_distance=50,\n", + " max_layout_filling=0.4,\n", + " min_atom_distance=4,\n", + " _channels=(\n", + " (\"rydberg_global\", Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5)),\n", + " ),\n", + " pre_calibrated_layouts=(\n", + " SquareLatticeLayout(10, 10, 4),\n", + " TriangularLatticeLayout(100, 5),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When receiving a `Device` instance, it may include the layouts that are already calibrated and available to be used. To access them, simply run:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "TestDevice.calibrated_register_layouts" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can then choose one of these layouts to define your `Register` and start creating a `Sequence`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "layout = TestDevice.calibrated_register_layouts[\n", + " \"SquareLatticeLayout(10x10, 4µm)\"\n", + "]\n", + "reg = layout.square_register(6)\n", + "seq = Sequence(reg, TestDevice)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "another_layout = SquareLatticeLayout(5, 5, 5)\n", + "assert another_layout not in TestDevice.pre_calibrated_layouts\n", + "reg_ = another_layout.square_register(3)\n", + "seq = Sequence(reg_, TestDevice)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "However, it is not possible to use a register created from an invalid layout, even if the register is valid:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bad_layout = TriangularLatticeLayout(\n", + " 200, 10\n", + ") # This layout is too large for TestDevice\n", + "good_reg = bad_layout.hexagonal_register(\n", + " 10\n", + ") # On its own, this register is valid in TestDevice\n", + "try:\n", + " seq = Sequence(good_reg, TestDevice)\n", + "except ValueError as e:\n", + " print(e)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Maximum Layout Filling Fraction" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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. \n", + "\n", + "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.\n", + "\n", + "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):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "layout = TestDevice.calibrated_register_layouts[\n", + " \"SquareLatticeLayout(10x10, 4µm)\"\n", + "]\n", + "too_big_reg = layout.square_register(7)\n", + "try:\n", + " seq = Sequence(too_big_reg, TestDevice)\n", + "except ValueError as e:\n", + " print(e)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Mappable Registers" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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.\n", + "\n", + "For example, below we define a mappable register with 10 qubits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "map_register = layout.make_mappable_register(n_qubits=10)\n", + "map_register.qubit_ids" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now use this register in our simple sequence:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "seq = Sequence(map_register, TestDevice)\n", + "assert seq.is_register_mappable()\n", + "\n", + "seq.declare_channel(\"rydberg\", \"rydberg_global\")\n", + "seq.add(\n", + " Pulse.ConstantPulse(duration=100, amplitude=1, detuning=0, phase=0),\n", + " \"rydberg\",\n", + ")\n", + "seq.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To define the register, we can then call `Sequence.build()`, indicated 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). \n", + "\n", + "In this way, we can build multiple sequences, with only the `Register` changing from one to the other:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "seq1 = seq.build(qubits={\"q0\": 16, \"q1\": 19, \"q4\": 34})\n", + "print(\"First register:\", seq1.register.qubits)\n", + "\n", + "seq2 = seq.build(qubits={\"q0\": 0, \"q1\": 15, \"q2\": 20})\n", + "print(\"Second register:\", seq2.register.qubits)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb new file mode 100644 index 000000000..b7427c51b --- /dev/null +++ b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb @@ -0,0 +1,622 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from pulser import Pulse, Sequence, Register\n", + "from pulser_simulation import Simulation\n", + "from pulser.devices import Chadoq2\n", + "from pulser.waveforms import InterpolatedWaveform\n", + "from scipy.optimize import minimize\n", + "from scipy.spatial.distance import pdist, squareform" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Introduction " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this tutorial, we illustrate how to solve a Quadratic Unconstrained Binary Optimization (QUBO) instance using an ensemble of Rydberg atoms in analog mode.\n", + "\n", + "QUBO has been extensively studied [Glover, et al., 2018](https://arxiv.org/pdf/1811.11538.pdf) and is used to model and solve numerous categories of optimization problems including important instances of network flows, scheduling, max-cut, max-clique, vertex cover and other graph and management science problems, integrating them into a unified modeling framework.\n", + "\n", + "Mathematically, a QUBO instance consists of a symmetric matrix $Q$ of size $(N \\times N)$, and the optimization problem associated with it is to find the bitstring $z=(z_1, \\dots, z_N) \\in \\{0, 1 \\}^N$ that minimizes the quantity\n", + "$$f(z) = z^{T}Qz$$ \n", + "\n", + "\n", + "In this tutorial, we will demonstrate how a QUBO instance can be mapped and solved using neutral atoms." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Suppose we are given the following QUBO matrix $Q$:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Q = np.array(\n", + " [\n", + " [-10.0, 19.7365809, 19.7365809, 5.42015853, 5.42015853],\n", + " [19.7365809, -10.0, 20.67626392, 0.17675796, 0.85604541],\n", + " [19.7365809, 20.67626392, -10.0, 0.85604541, 0.17675796],\n", + " [5.42015853, 0.17675796, 0.85604541, -10.0, 0.32306662],\n", + " [5.42015853, 0.85604541, 0.17675796, 0.32306662, -10.0],\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Because the QUBO is small, we can classically check all solutions and mark the optimal ones. This will help us later in the tutorial to visualize the quality of our quantum approach." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bitstrings = [np.binary_repr(i, len(Q)) for i in range(len(Q) ** 2)]\n", + "costs = []\n", + "for (\n", + " b\n", + ") in bitstrings: # this takes exponential time with the dimension of the QUBO\n", + " z = np.array(list(b), dtype=int)\n", + " cost = z.T @ Q @ z\n", + " costs.append(cost)\n", + "zipped = zip(bitstrings, costs)\n", + "sort_zipped = sorted(zipped, key=lambda x: x[1])\n", + "print(sort_zipped[:3])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This QUBO admits `01011` and `00111` as optimal solutions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Embedding a QUBO onto an atomic register" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now illustrate how to use Pulser to embbed the QUBO matrix $Q$ on a neutral-atom device.\n", + "\n", + "The key idea is to encode the off-diagonal terms of $Q$ by using the Rydberg interaction between atoms. As the interaction $U$ depends on the pairwise distance ($U=C_6/r_{ij}^6$) between atoms $i$ and $j$, we attempt to find the optimal positions of the atoms in the Register that replicate best the off-diagonal terms of $Q$:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_mapping(new_coords, *args):\n", + " \"\"\"Cost function to minimize. Ideally, the pairwise\n", + " distances are conserved\"\"\"\n", + " Q, shape = args\n", + " new_coords = np.reshape(new_coords, shape)\n", + " new_Q = squareform(Chadoq2.interaction_coeff / pdist(new_coords) ** 6)\n", + " return np.linalg.norm(new_Q - Q)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "shape = (len(Q), 2)\n", + "costs = []\n", + "np.random.seed(0)\n", + "x0 = np.random.random(shape).flatten()\n", + "res = minimize(\n", + " evaluate_mapping,\n", + " x0,\n", + " args=(Q, shape),\n", + " method=\"Nelder-Mead\",\n", + " tol=1e-6,\n", + " options={\"maxiter\": 200000, \"maxfev\": None},\n", + ")\n", + "coords = np.reshape(res.x, (len(Q), 2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then plot the obtained coordinates in a Register using:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "qubits = dict(enumerate(coords))\n", + "reg = Register(qubits)\n", + "reg.draw(\n", + " blockade_radius=Chadoq2.rydberg_blockade_radius(1.0),\n", + " draw_graph=False,\n", + " draw_half_radius=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Building the quantum algorithm " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that the QUBO $Q$ is encoded in the Register, we can peprare the following Ising Hamiltonian $H_Q$:\n", + "\n", + "$$ H= \\sum_{i=1}^N \\frac{\\hbar\\Omega}{2} \\sigma_i^x - \\sum_{i=1}^N \\frac{\\hbar \\delta}{2} \\sigma_i^z+\\sum_{j90% quality solution without going to high depths of the QAOA, implying that the growing closed-loop optimization can rapidly become expensive, with no guarantee of convergence. We therefore propose another approach called the Quantum Adiabatic Algorithm (QAA). This fast, reliant and exclusively analog method shows optimal convergence to the solution." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Quantum Adiabatic Algorithm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The idea behind the adiabatic algorithm (see [Albash, Lidar, 2018](https://arxiv.org/pdf/1611.04471.pdf)) is to slowly evolve the system from an easy-to-prepare groundstate to the groundstate of $H_Q$. If done slowly enough, the system of atoms stays in the instantaneous ground-state.\n", + "\n", + "In our case, we continuously vary the parameters $\\Omega(t), \\delta(t)$ in time, starting with $\\Omega(0)=0, \\delta(0)<0$ and ending with $\\Omega(0)=0, \\delta>0$. The ground-state of $H(0)$ corresponds to the initial state $|00000\\rangle$ and the ground-state of $H(t_f)$ corresponds to the ground-state of $H_Q$." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Rydberg blockade radius is directly linked to the Rabi frequency $\\Omega$ and is obtained using `Chadoq2.rydberg_blockade_radius()`. In this notebook, $\\Omega$ is initially fixed to a frequency of 1 rad/µs. We can therefore build the adjacency matrix $A$ of $G$ in the following way:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To ensure that we are not exciting the system to states that are too excited, we keep $\\Omega \\in [0, \\Omega_{\\text{max}}]$, and choose $\\Omega_{\\text{max}}$ as the median of the values of Q to ensures that the adiabatic path is efficient." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# We choose a median value between the min and the max\n", + "Omega = np.median(Q[Q > 0].flatten())\n", + "delta_0 = -5 # just has to be negative\n", + "delta_f = -delta_0 # just has to be positive\n", + "T = 4000 # time in ns, we choose a time long enough to ensure the propagation of information in the system" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "adiabatic_pulse = Pulse(\n", + " InterpolatedWaveform(T, [1e-9, Omega, 1e-9]),\n", + " InterpolatedWaveform(T, [delta_0, 0, delta_f]),\n", + " 0,\n", + ")\n", + "seq = Sequence(reg, Chadoq2)\n", + "seq.declare_channel(\"ising\", \"rydberg_global\")\n", + "seq.add(adiabatic_pulse, \"ising\")\n", + "seq.draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "simul = Simulation(seq)\n", + "results = simul.run()\n", + "final = results.get_final_state()\n", + "count_dict = results.sample_final_state()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plot_distribution(count_dict)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See how fast and performant this method is! In only a few micro-seconds, we find an excellent solution." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### How does the time evolution affect the quality of the results?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cost = []\n", + "for T in 1000 * np.linspace(1, 10, 10):\n", + " seq = Sequence(reg, Chadoq2)\n", + " seq.declare_channel(\"ising\", \"rydberg_global\")\n", + " adiabatic_pulse = Pulse(\n", + " InterpolatedWaveform(T, [1e-9, Omega, 1e-9]),\n", + " InterpolatedWaveform(T, [delta_0, 0, delta_f]),\n", + " 0,\n", + " )\n", + " seq.add(adiabatic_pulse, \"ising\")\n", + " simul = Simulation(seq)\n", + " results = simul.run()\n", + " final = results.get_final_state()\n", + " count_dict = results.sample_final_state()\n", + " cost.append(get_cost(count_dict, Q) / 3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure(figsize=(12, 6))\n", + "plt.plot(range(1, 11), np.array(cost), \"--o\")\n", + "plt.xlabel(\"total time evolution (µs)\", fontsize=14)\n", + "plt.ylabel(\"cost\", fontsize=14)\n", + "plt.show()" + ] + } + ], + "metadata": { + "celltoolbar": "Tags", + "interpreter": { + "hash": "949777d72b0d2535278d3dc13498b2535136f6dfe0678499012e853ee9abcab1" + }, + "kernelspec": { + "display_name": "Python 3.10.7 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.7" + }, + "vscode": { + "interpreter": { + "hash": "e088768f7ff7b4294439f8ed10f7eed9e3b885124bc20d9d06cc2a37b1883330" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}