From c0e72c92c303d8cfbcbe8e5bc6fe2a831fda0475 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Mon, 31 Oct 2022 14:49:22 +0100 Subject: [PATCH 1/6] Creating the `pulser.channels` module --- pulser-core/pulser/channels/__init__.py | 16 ++++++++++++++++ pulser-core/pulser/{ => channels}/channels.py | 4 ++-- pulser-core/pulser/devices/_device_datacls.py | 2 +- pulser-core/pulser/json/supported.py | 2 +- pulser-core/pulser/pulse.py | 2 +- pulser-core/pulser/sampler/samples.py | 2 +- pulser-core/pulser/sequence/_schedule.py | 2 +- pulser-core/pulser/sequence/_seq_drawer.py | 2 +- pulser-core/pulser/sequence/sequence.py | 2 +- pulser-core/pulser/waveforms.py | 2 +- tests/test_channels.py | 2 +- 11 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 pulser-core/pulser/channels/__init__.py rename pulser-core/pulser/{ => channels}/channels.py (99%) diff --git a/pulser-core/pulser/channels/__init__.py b/pulser-core/pulser/channels/__init__.py new file mode 100644 index 000000000..44d307e9e --- /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 Rydberg, Raman, Microwave diff --git a/pulser-core/pulser/channels.py b/pulser-core/pulser/channels/channels.py similarity index 99% rename from pulser-core/pulser/channels.py rename to pulser-core/pulser/channels/channels.py index a8a866360..c17bdb65e 100644 --- a/pulser-core/pulser/channels.py +++ b/pulser-core/pulser/channels/channels.py @@ -11,7 +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. -"""The various hardware channel types.""" +"""Defines the Channel ABC and its subclasses.""" from __future__ import annotations @@ -475,7 +475,7 @@ def __repr__(self) -> str: return self.name + config def _to_dict(self) -> dict[str, Any]: - return obj_to_dict(self, **asdict(self)) + return obj_to_dict(self, _module="pulser.channels", **asdict(self)) @dataclass(init=True, repr=False, frozen=True) diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index 799404f8f..e2a6aff54 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -23,7 +23,7 @@ import numpy as np from scipy.spatial.distance import pdist, squareform -from pulser.channels import Channel +from pulser.channels.channels import Channel from pulser.devices.interaction_coefficients import c6_dict from pulser.json.utils import obj_to_dict from pulser.register.base_register import BaseRegister, QubitId diff --git a/pulser-core/pulser/json/supported.py b/pulser-core/pulser/json/supported.py index 9e640d026..62c86c4b9 100644 --- a/pulser-core/pulser/json/supported.py +++ b/pulser-core/pulser/json/supported.py @@ -18,7 +18,7 @@ from typing import Any, Mapping import pulser.devices as devices -from pulser.channels import CH_TYPE, get_args +from pulser.channels.channels import CH_TYPE, get_args from pulser.json.exceptions import SerializationError SUPPORTED_BUILTINS = ("float", "int", "str", "set") diff --git a/pulser-core/pulser/pulse.py b/pulser-core/pulser/pulse.py index ac3671dd8..20dd12d2c 100644 --- a/pulser-core/pulser/pulse.py +++ b/pulser-core/pulser/pulse.py @@ -30,7 +30,7 @@ from pulser.waveforms import ConstantWaveform, Waveform if TYPE_CHECKING: - from pulser.channels import Channel + from pulser.channels.channels import Channel @dataclass(init=False, repr=False, frozen=True) diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 4459a0488..8f8e7efee 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -7,7 +7,7 @@ import numpy as np -from pulser.channels import Channel +from pulser.channels.channels import Channel from pulser.register import QubitId """Literal constants for addressing.""" diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index ab1760ae0..8ed6077ce 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -21,7 +21,7 @@ import numpy as np -from pulser.channels import Channel +from pulser.channels.channels import Channel from pulser.pulse import Pulse from pulser.register.base_register import QubitId from pulser.sampler.samples import ChannelSamples, _TargetSlot diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index 6080e70f1..e792fdcec 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.channels import Channel from pulser.pulse import Pulse from pulser.sampler.samples import ChannelSamples from pulser.waveforms import InterpolatedWaveform diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index 707dd30df..10ceed12b 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -29,7 +29,7 @@ import pulser import pulser.sequence._decorators as seq_decorators -from pulser.channels import Channel +from pulser.channels.channels import Channel from pulser.devices._device_datacls import BaseDevice from pulser.json.abstract_repr.deserializer import ( deserialize_abstract_sequence, diff --git a/pulser-core/pulser/waveforms.py b/pulser-core/pulser/waveforms.py index 1167c4dd7..7e8e5f86e 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -38,7 +38,7 @@ from pulser.parametrized.decorators import parametrize if TYPE_CHECKING: - from pulser.channels import Channel + from pulser.channels.channels import Channel if version_info[:2] >= (3, 8): # pragma: no cover from functools import cached_property diff --git a/tests/test_channels.py b/tests/test_channels.py index 18b6be8dd..0dd96ee37 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -109,7 +109,7 @@ def test_device_channels(): 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"] From a194bf87e1affc7dc318c764fa191eae9c878d7a Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Wed, 9 Nov 2022 17:27:24 +0100 Subject: [PATCH 2/6] Create the EOM related classes --- pulser-core/pulser/channels/__init__.py | 2 +- pulser-core/pulser/channels/channels.py | 81 ++++++++--- pulser-core/pulser/channels/eom.py | 170 ++++++++++++++++++++++++ pulser-core/pulser/devices/_devices.py | 9 ++ pulser-core/pulser/json/supported.py | 1 + pulser-core/pulser/pulse.py | 15 ++- pulser-core/pulser/waveforms.py | 22 ++- tests/conftest.py | 15 +++ tests/test_channels.py | 86 ++++++++++-- tests/test_eom.py | 120 +++++++++++++++++ 10 files changed, 477 insertions(+), 44 deletions(-) create mode 100644 pulser-core/pulser/channels/eom.py create mode 100644 tests/test_eom.py diff --git a/pulser-core/pulser/channels/__init__.py b/pulser-core/pulser/channels/__init__.py index 44d307e9e..9645bddbd 100644 --- a/pulser-core/pulser/channels/__init__.py +++ b/pulser-core/pulser/channels/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """The various hardware channel types.""" -from pulser.channels.channels import Rydberg, Raman, Microwave +from pulser.channels.channels import Microwave, Raman, Rydberg diff --git a/pulser-core/pulser/channels/channels.py b/pulser-core/pulser/channels/channels.py index c17bdb65e..be41b4905 100644 --- a/pulser-core/pulser/channels/channels.py +++ b/pulser-core/pulser/channels/channels.py @@ -17,7 +17,7 @@ import warnings from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass +from dataclasses import dataclass, field, fields from sys import version_info from typing import Any, Optional, cast @@ -25,6 +25,7 @@ from numpy.typing import ArrayLike from scipy.fft import fft, fftfreq, ifft +from pulser.channels.eom import MODBW_TO_TR, BaseEOM, RydbergEOM from pulser.json.utils import obj_to_dict from pulser.pulse import Pulse @@ -43,10 +44,6 @@ # 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 - ADDRESSING = Literal["Global", "Local"] CH_TYPE = Literal["Rydberg", "Raman", "Microwave"] BASIS = Literal["ground-rydberg", "digital", "XY"] @@ -94,6 +91,7 @@ class Channel(ABC): 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: @@ -210,6 +208,10 @@ 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", @@ -366,7 +368,10 @@ def validate_pulse(self, pulse: Pulse) -> None: ) def modulate( - self, input_samples: np.ndarray, keep_ends: bool = False + self, + input_samples: np.ndarray, + keep_ends: bool = False, + eom: bool = False, ) -> np.ndarray: """Modulates the input according to the channel's modulation bandwidth. @@ -374,33 +379,43 @@ def modulate( 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 not self.mod_bandwidth: + 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 = self.mod_bandwidth * 1e-3 / np.sqrt(np.log(2)) + fc = mod_bandwidth * 1e-3 / np.sqrt(np.log(2)) if keep_ends: - samples = np.pad(input_samples, 2 * self.rise_time, mode="edge") + samples = np.pad(input_samples, 2 * rise_time, mode="edge") else: - samples = np.pad(input_samples, self.rise_time) + 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[self.rise_time : -self.rise_time] - ) + return cast(np.ndarray, mod_samples[rise_time:-rise_time]) return cast(np.ndarray, mod_samples) def calc_modulation_buffer( @@ -408,6 +423,7 @@ def calc_modulation_buffer( 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. @@ -417,17 +433,23 @@ def calc_modulation_buffer( ``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 not self.mod_bandwidth: - raise TypeError( - f"The channel {self} doesn't have a modulation bandwidth." - ) - - tr = self.rise_time + 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: @@ -475,7 +497,10 @@ def __repr__(self) -> str: return self.name + config def _to_dict(self) -> dict[str, Any]: - return obj_to_dict(self, _module="pulser.channels", **asdict(self)) + params = { + f.name: getattr(self, f.name) for f in fields(self) if f.init + } + return obj_to_dict(self, _module="pulser.channels", **params) @dataclass(init=True, repr=False, frozen=True) @@ -500,6 +525,22 @@ class Rydberg(Channel): 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.""" diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py new file mode 100644 index 000000000..7b8f60f76 --- /dev/null +++ b/pulser-core/pulser/channels/eom.py @@ -0,0 +1,170 @@ +# 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 the 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, List, 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) + + +@dataclass(frozen=True) +class BaseEOM: + """A base class for the EOM configuration.""" + + 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) + + +@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_beam: 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 + ) -> list[float]: + """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. + """ + offset = detuning_on - self._lightshift(rabi_frequency, *RydbergBeam) + if len(self.controlled_beams) == 1: + lightshifts = [ + self._lightshift(rabi_frequency, ~self.controlled_beams[0]) + ] + + else: + lightshifts = [ + self._lightshift(rabi_frequency, beam) + for beam in self.controlled_beams + ] + # Extra case where both beams are off + lightshifts.append(0.0) + return cast(List[float], (offset + np.array(lightshifts)).tolist()) + + def _lightshift( + self, rabi_frequency: float, *beams_on: RydbergBeam + ) -> float: + rabi_freqs = self._rabi_freq_per_beam(rabi_frequency) + bias = {RydbergBeam.RED: -1, RydbergBeam.BLUE: 1} + 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]: + limit_rabi_freq = self.max_limiting_amp**2 / ( + 2 * self.intermediate_detuning + ) + if rabi_frequency <= limit_rabi_freq: + beam_amp = np.sqrt(2 * rabi_frequency * self.intermediate_detuning) + return {beam: beam_amp for beam in RydbergBeam} + 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/_devices.py b/pulser-core/pulser/devices/_devices.py index 604599b0b..7c9db9738 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( @@ -85,6 +86,14 @@ 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/json/supported.py b/pulser-core/pulser/json/supported.py index 62c86c4b9..130d79cf9 100644 --- a/pulser-core/pulser/json/supported.py +++ b/pulser-core/pulser/json/supported.py @@ -74,6 +74,7 @@ [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/pulse.py b/pulser-core/pulser/pulse.py index 20dd12d2c..712576c1d 100644 --- a/pulser-core/pulser/pulse.py +++ b/pulser-core/pulser/pulse.py @@ -23,6 +23,7 @@ import matplotlib.pyplot as plt import numpy as np +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 @@ -190,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/waveforms.py b/pulser-core/pulser/waveforms.py index 7e8e5f86e..ab0acc9d1 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -163,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 @@ -195,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 @@ -207,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/tests/conftest.py b/tests/conftest.py index 8b605b82a..d10213f7f 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 @@ -39,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,), + ), ), ), ( @@ -53,6 +61,13 @@ def mod_device() -> Device: 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), + ), ), ), ( diff --git a/tests/test_channels.py b/tests/test_channels.py index 0dd96ee37..756082b8b 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -19,6 +19,7 @@ import pulser from pulser.channels import Microwave, Raman, Rydberg +from pulser.channels.eom import BaseEOM, RydbergBeam, RydbergEOM from pulser.waveforms import BlackmanWaveform, ConstantWaveform @@ -168,16 +169,50 @@ def test_repr(): 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) @@ -186,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_eom.py b/tests/test_eom.py new file mode 100644 index 000000000..c51714e0d --- /dev/null +++ b/tests/test_eom.py @@ -0,0 +1,120 @@ +# 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): + if amp <= limit_amp: + return 0.0 + assert params["limiting_beam"] == RydbergBeam.RED + red_amp = params["max_limiting_amp"] + blue_amp = 2 * params["intermediate_detuning"] * amp / red_amp + return -(blue_amp**2 - red_amp**2) / ( + 4 * params["intermediate_detuning"] + ) + + zero_det = calc_offset(amp) + assert eom._lightshift(amp, *RydbergBeam) == -zero_det + assert eom._lightshift(amp) == 0.0 + det_off_options = eom.detuning_off_options(amp, 0.0) + 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 + + detuning_on = 1.0 + for beam, ind in [(RydbergBeam.RED, 2), (RydbergBeam.BLUE, 0)]: + params["controlled_beams"] = (beam,) + eom_ = RydbergEOM(**params) + off_options = eom_.detuning_off_options(amp, detuning_on) + assert len(off_options) == 1 + assert off_options[0] == det_off_options[ind] + detuning_on From 37547338010af39c8ad346814682ef9743f261e3 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Fri, 25 Nov 2022 09:45:56 +0100 Subject: [PATCH 3/6] Splitting channels.py into two files --- pulser-core/pulser/channels/base_channel.py | 503 ++++++++++++++++++ pulser-core/pulser/channels/channels.py | 491 +---------------- pulser-core/pulser/devices/_device_datacls.py | 2 +- pulser-core/pulser/json/supported.py | 2 +- pulser-core/pulser/pulse.py | 2 +- pulser-core/pulser/sampler/samples.py | 2 +- pulser-core/pulser/sequence/_schedule.py | 2 +- pulser-core/pulser/sequence/_seq_drawer.py | 2 +- pulser-core/pulser/sequence/sequence.py | 2 +- pulser-core/pulser/waveforms.py | 2 +- 10 files changed, 516 insertions(+), 494 deletions(-) create mode 100644 pulser-core/pulser/channels/base_channel.py diff --git a/pulser-core/pulser/channels/base_channel.py b/pulser-core/pulser/channels/base_channel.py new file mode 100644 index 000000000..5eed1d74a --- /dev/null +++ b/pulser-core/pulser/channels/base_channel.py @@ -0,0 +1,503 @@ +# 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") + +ADDRESSING = Literal["Global", "Local"] +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). + 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% reduction), in + MHz. + + Example: + To create a channel targeting the 'ground-rydberg' transition globally, + call ``Rydberg.Global(...)``. + """ + + addressing: ADDRESSING + max_abs_detuning: Optional[float] + max_amp: Optional[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 = 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", ADDRESSING), + ] + 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", + "phase_jump_time", + "clock_period", + "min_duration", + "max_duration", + "mod_bandwidth", + ] + non_negative = [ + "max_abs_detuning", + "phase_jump_time", + "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 + + 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], + phase_jump_time: int = 0, + 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). + phase_jump_time: Time taken to change the phase between + consecutive pulses (in ns). + 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, + phase_jump_time, + min_retarget_interval, + fixed_retarget_t, + max_targets, + **kwargs, + ) + + @classmethod + def Global( + cls, + max_abs_detuning: Optional[float], + max_amp: Optional[float], + phase_jump_time: int = 0, + **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). + phase_jump_time: Time taken to change the phase between + consecutive pulses (in ns). + + 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, 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 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 ''}, " + f"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 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) diff --git a/pulser-core/pulser/channels/channels.py b/pulser-core/pulser/channels/channels.py index be41b4905..7f41ea89a 100644 --- a/pulser-core/pulser/channels/channels.py +++ b/pulser-core/pulser/channels/channels.py @@ -11,496 +11,15 @@ # 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 and its subclasses.""" +"""Defines the Channel subclasses.""" 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 +from dataclasses import dataclass +from typing import Optional -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, RydbergEOM -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") - -ADDRESSING = Literal["Global", "Local"] -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). - 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% reduction), in - MHz. - - Example: - To create a channel targeting the 'ground-rydberg' transition globally, - call ``Rydberg.Global(...)``. - """ - - addressing: ADDRESSING - max_abs_detuning: Optional[float] - max_amp: Optional[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 = 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", ADDRESSING), - ] - 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", - "phase_jump_time", - "clock_period", - "min_duration", - "max_duration", - "mod_bandwidth", - ] - non_negative = [ - "max_abs_detuning", - "phase_jump_time", - "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 - - 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], - phase_jump_time: int = 0, - 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). - phase_jump_time: Time taken to change the phase between - consecutive pulses (in ns). - 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, - phase_jump_time, - min_retarget_interval, - fixed_retarget_t, - max_targets, - **kwargs, - ) - - @classmethod - def Global( - cls, - max_abs_detuning: Optional[float], - max_amp: Optional[float], - phase_jump_time: int = 0, - **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). - phase_jump_time: Time taken to change the phase between - consecutive pulses (in ns). - - 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, 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 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 ''}, " - f"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 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) +from pulser.channels.base_channel import Channel, Literal +from pulser.channels.eom import RydbergEOM @dataclass(init=True, repr=False, frozen=True) diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index e2a6aff54..ec194c319 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -23,7 +23,7 @@ import numpy as np from scipy.spatial.distance import pdist, squareform -from pulser.channels.channels import Channel +from pulser.channels.base_channel import Channel from pulser.devices.interaction_coefficients import c6_dict from pulser.json.utils import obj_to_dict from pulser.register.base_register import BaseRegister, QubitId diff --git a/pulser-core/pulser/json/supported.py b/pulser-core/pulser/json/supported.py index a243e2c0b..2b5338478 100644 --- a/pulser-core/pulser/json/supported.py +++ b/pulser-core/pulser/json/supported.py @@ -18,7 +18,7 @@ from typing import Any, Mapping import pulser.devices as devices -from pulser.channels.channels import CH_TYPE, get_args +from pulser.channels.base_channel import CH_TYPE, get_args from pulser.json.exceptions import SerializationError SUPPORTED_BUILTINS = ("float", "int", "str", "set") diff --git a/pulser-core/pulser/pulse.py b/pulser-core/pulser/pulse.py index 712576c1d..0245f4fd1 100644 --- a/pulser-core/pulser/pulse.py +++ b/pulser-core/pulser/pulse.py @@ -31,7 +31,7 @@ from pulser.waveforms import ConstantWaveform, Waveform if TYPE_CHECKING: - from pulser.channels.channels import Channel + from pulser.channels.base_channel import Channel @dataclass(init=False, repr=False, frozen=True) diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 8f8e7efee..3abdb3e6b 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -7,7 +7,7 @@ import numpy as np -from pulser.channels.channels import Channel +from pulser.channels.base_channel import Channel from pulser.register import QubitId """Literal constants for addressing.""" diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index 8ed6077ce..28ad81ce5 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -21,7 +21,7 @@ import numpy as np -from pulser.channels.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 diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index e792fdcec..5f3206d96 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.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 diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index e1e8a8e5b..22c71f952 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -29,7 +29,7 @@ import pulser import pulser.sequence._decorators as seq_decorators -from pulser.channels.channels import Channel +from pulser.channels.base_channel import Channel from pulser.devices._device_datacls import BaseDevice from pulser.json.abstract_repr.deserializer import ( deserialize_abstract_sequence, diff --git a/pulser-core/pulser/waveforms.py b/pulser-core/pulser/waveforms.py index ab0acc9d1..13e806885 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -38,7 +38,7 @@ from pulser.parametrized.decorators import parametrize if TYPE_CHECKING: - from pulser.channels.channels import Channel + from pulser.channels.base_channel import Channel if version_info[:2] >= (3, 8): # pragma: no cover from functools import cached_property From 867b6222be83c83c55b58cac4ba496d910be1835 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Fri, 25 Nov 2022 17:25:55 +0100 Subject: [PATCH 4/6] Comments for detuning_off UT --- tests/test_eom.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_eom.py b/tests/test_eom.py index c51714e0d..95e3d0dc7 100644 --- a/tests/test_eom.py +++ b/tests/test_eom.py @@ -93,28 +93,39 @@ def test_detuning_off(limit_amp_fraction, params): 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"] ) - zero_det = calc_offset(amp) + # 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, 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 From 0e4fcd1ed7b6fe8aae54106940ea180a6a264d85 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Fri, 25 Nov 2022 18:35:38 +0100 Subject: [PATCH 5/6] Documentation updates --- docs/source/apidoc/core.rst | 19 +++++++++++++++++-- pulser-core/pulser/channels/base_channel.py | 5 ++--- pulser-core/pulser/channels/channels.py | 2 +- pulser-core/pulser/channels/eom.py | 11 ++++++++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/source/apidoc/core.rst b/docs/source/apidoc/core.rst index 94389c765..c0a3de88f 100644 --- a/docs/source/apidoc/core.rst +++ b/docs/source/apidoc/core.rst @@ -98,12 +98,27 @@ which when associated with a :class:`pulser.Sequence` condition its development. 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/pulser-core/pulser/channels/base_channel.py b/pulser-core/pulser/channels/base_channel.py index 5eed1d74a..4b86c37cb 100644 --- a/pulser-core/pulser/channels/base_channel.py +++ b/pulser-core/pulser/channels/base_channel.py @@ -44,7 +44,6 @@ # Warnings of adjusted waveform duration appear just once warnings.filterwarnings("once", "A duration of") -ADDRESSING = Literal["Global", "Local"] CH_TYPE = Literal["Rydberg", "Raman", "Microwave"] BASIS = Literal["ground-rydberg", "digital", "XY"] @@ -80,7 +79,7 @@ class Channel(ABC): call ``Rydberg.Global(...)``. """ - addressing: ADDRESSING + addressing: Literal["Global", "Local"] max_abs_detuning: Optional[float] max_amp: Optional[float] phase_jump_time: int = 0 @@ -114,7 +113,7 @@ def __post_init__(self) -> None: internal_param_value_pairs = [ ("name", CH_TYPE), ("basis", BASIS), - ("addressing", ADDRESSING), + ("addressing", Literal["Global", "Local"]), ] for param, type_options in internal_param_value_pairs: value = getattr(self, param) diff --git a/pulser-core/pulser/channels/channels.py b/pulser-core/pulser/channels/channels.py index 7f41ea89a..0373b3879 100644 --- a/pulser-core/pulser/channels/channels.py +++ b/pulser-core/pulser/channels/channels.py @@ -11,7 +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. -"""Defines the Channel subclasses.""" +"""The Channel subclasses.""" from __future__ import annotations diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py index 7b8f60f76..9b4197024 100644 --- a/pulser-core/pulser/channels/eom.py +++ b/pulser-core/pulser/channels/eom.py @@ -11,7 +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. -"""Configuration parameters for the a channel's EOM.""" +"""Configuration parameters for a channel's EOM.""" from __future__ import annotations from dataclasses import dataclass, fields @@ -40,7 +40,12 @@ def _to_dict(self) -> dict[str, Any]: @dataclass(frozen=True) class BaseEOM: - """A base class for the EOM configuration.""" + """A base class for the EOM configuration. + + Attributes: + mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), + in MHz. + """ mod_bandwidth: float # MHz @@ -75,7 +80,7 @@ class RydbergEOM(BaseEOM): mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), in MHz. limiting_beam: The beam with the smallest amplitude range. - max_limiting_beam: The maximum amplitude the limiting beam can reach, + 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. From 1afc54e0559556467f779a3af0439cb8ee3fc213 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Mon, 28 Nov 2022 11:23:55 +0100 Subject: [PATCH 6/6] Adding comments to the EOMConfig class --- pulser-core/pulser/channels/eom.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py index 9b4197024..7d869b96f 100644 --- a/pulser-core/pulser/channels/eom.py +++ b/pulser-core/pulser/channels/eom.py @@ -133,26 +133,39 @@ def detuning_off_options( 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 ] - # Extra case where both beams are off + # 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 cast(List[float], (offset + np.array(lightshifts)).tolist()) 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 ) @@ -160,12 +173,19 @@ def _lightshift( 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 the 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