diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e09976b2b..dcda49311 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3,12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - name: Check out Pulser uses: actions/checkout@v4 diff --git a/VERSION.txt b/VERSION.txt index 249afd517..1cf0537c3 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.18.1 +0.19.0 diff --git a/pulser-core/pulser/backend/abc.py b/pulser-core/pulser/backend/abc.py index 56d612fa2..704e91d55 100644 --- a/pulser-core/pulser/backend/abc.py +++ b/pulser-core/pulser/backend/abc.py @@ -17,6 +17,7 @@ import typing from abc import ABC, abstractmethod +from pulser.devices import Device from pulser.result import Result from pulser.sequence import Sequence @@ -26,20 +27,46 @@ class Backend(ABC): """The backend abstract base class.""" - def __init__(self, sequence: Sequence) -> None: + def __init__(self, sequence: Sequence, mimic_qpu: bool = False) -> None: """Starts a new backend instance.""" - self.validate_sequence(sequence) + self.validate_sequence(sequence, mimic_qpu=mimic_qpu) self._sequence = sequence + self._mimic_qpu = bool(mimic_qpu) @abstractmethod def run(self) -> Results | typing.Sequence[Results]: """Executes the sequence on the backend.""" pass - def validate_sequence(self, sequence: Sequence) -> None: + @staticmethod + def validate_sequence(sequence: Sequence, mimic_qpu: bool = False) -> None: """Validates a sequence prior to submission.""" if not isinstance(sequence, Sequence): raise TypeError( "'sequence' should be a `Sequence` instance" f", not {type(sequence)}." ) + if not mimic_qpu: + return + + if not isinstance(device := sequence.device, Device): + raise TypeError( + "To be sent to a QPU, the device of the sequence " + "must be a real device, instance of 'Device'." + ) + reg = sequence.get_register(include_mappable=True) + if device.requires_layout and (layout := reg.layout) is None: + raise ValueError( + f"'{device.name}' requires the sequence's register to be" + " defined from a `RegisterLayout`." + ) + if ( + not device.accepts_new_layouts + and layout is not None + and layout not in device.pre_calibrated_layouts + ): + raise ValueError( + f"'{device.name}' does not accept new register layouts so " + "the register's layout must be one of the layouts available " + f"in '{device.name}.calibrated_register_layouts'." + ) diff --git a/pulser-core/pulser/backend/qpu.py b/pulser-core/pulser/backend/qpu.py index e7c30f89f..f46f07be8 100644 --- a/pulser-core/pulser/backend/qpu.py +++ b/pulser-core/pulser/backend/qpu.py @@ -17,12 +17,29 @@ from typing import cast from pulser import Sequence -from pulser.backend.remote import JobParams, RemoteBackend, RemoteResults -from pulser.devices import Device +from pulser.backend.remote import ( + JobParams, + RemoteBackend, + RemoteConnection, + RemoteResults, +) class QPUBackend(RemoteBackend): - """Backend for sequence execution on a QPU.""" + """Backend for sequence execution on a QPU. + + Args: + sequence: A Sequence or a list of Sequences to execute on a + backend accessible via a remote connection. + connection: The remote connection through which the jobs + are executed. + """ + + def __init__( + self, sequence: Sequence, connection: RemoteConnection + ) -> None: + """Starts a new QPU backend instance.""" + super().__init__(sequence, connection, mimic_qpu=True) def run( self, job_params: list[JobParams] | None = None, wait: bool = False @@ -45,11 +62,23 @@ def run( The results, which can be accessed once all sequences have been successfully executed. """ + self.validate_job_params( + job_params or [], self._sequence.device.max_runs + ) + results = self._connection.submit( + self._sequence, job_params=job_params, wait=wait + ) + return cast(RemoteResults, results) + + @staticmethod + def validate_job_params( + job_params: list[JobParams], max_runs: int | None + ) -> None: + """Validates a list of job parameters prior to submission.""" suffix = " when executing a sequence on a real QPU." if not job_params: raise ValueError("'job_params' must be specified" + suffix) - - max_runs = self._sequence.device.max_runs + RemoteBackend._type_check_job_params(job_params) for j in job_params: if "runs" not in j: raise ValueError( @@ -60,20 +89,3 @@ def run( "All 'runs' must be below the maximum allowed by the " f"device ({max_runs})" + suffix ) - results = self._connection.submit( - self._sequence, job_params=job_params, wait=wait - ) - return cast(RemoteResults, results) - - def validate_sequence(self, sequence: Sequence) -> None: - """Validates a sequence prior to submission. - - Args: - sequence: The sequence to validate. - """ - super().validate_sequence(sequence) - if not isinstance(sequence.device, Device): - raise TypeError( - "To be sent to a QPU, the device of the sequence " - "must be a real device, instance of 'Device'." - ) diff --git a/pulser-core/pulser/backend/remote.py b/pulser-core/pulser/backend/remote.py index aa07dccfe..92809b623 100644 --- a/pulser-core/pulser/backend/remote.py +++ b/pulser-core/pulser/backend/remote.py @@ -131,17 +131,33 @@ class RemoteBackend(Backend): backend accessible via a remote connection. connection: The remote connection through which the jobs are executed. + mimic_qpu: Whether to mimic the validations necessary for + execution on a QPU. """ def __init__( self, sequence: Sequence, connection: RemoteConnection, + mimic_qpu: bool = False, ) -> None: """Starts a new remote backend instance.""" - super().__init__(sequence) + super().__init__(sequence, mimic_qpu=mimic_qpu) if not isinstance(connection, RemoteConnection): raise TypeError( "'connection' must be a valid RemoteConnection instance." ) self._connection = connection + + @staticmethod + def _type_check_job_params(job_params: list[JobParams] | None) -> None: + if not isinstance(job_params, list): + raise TypeError( + f"'job_params' must be a list; got {type(job_params)} instead." + ) + for d in job_params: + if not isinstance(d, dict): + raise TypeError( + "All elements of 'job_params' must be dictionaries; " + f"got {type(d)} instead." + ) diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py index a75598d1e..6abba7838 100644 --- a/pulser-core/pulser/channels/eom.py +++ b/pulser-core/pulser/channels/eom.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, fields from enum import Flag from itertools import chain -from typing import Any, cast +from typing import Any, Literal, cast, overload import numpy as np @@ -26,7 +26,12 @@ # Conversion factor from modulation bandwith to rise time # For more info, see https://tinyurl.com/bdeumc8k MODBW_TO_TR = 0.48 -OPTIONAL_ABSTR_EOM_FIELDS = ("multiple_beam_control", "custom_buffer_time") +OPTIONAL_ABSTR_EOM_FIELDS = ( + "multiple_beam_control", + "custom_buffer_time", + "blue_shift_coeff", + "red_shift_coeff", +) class RydbergBeam(Flag): @@ -132,6 +137,8 @@ class _RydbergEOM: @dataclass(frozen=True) class _RydbergEOMDefaults: multiple_beam_control: bool = True + blue_shift_coeff: float = 1.0 + red_shift_coeff: float = 1.0 @dataclass(frozen=True) @@ -149,11 +156,20 @@ class RydbergEOM(_RydbergEOMDefaults, BaseEOM, _RydbergEOM): custom_buffer_time: A custom wait time to enforce during EOM buffers. multiple_beam_control: Whether both EOMs can be used simultaneously. Ignored when only one beam can be controlled. + blue_shift_coeff: The weight coefficient of the blue beam's + contribution to the lightshift. + red_shift_coeff: The weight coefficient of the red beam's contribution + to the lightshift. """ def __post_init__(self) -> None: super().__post_init__() - for param in ["max_limiting_amp", "intermediate_detuning"]: + for param in [ + "max_limiting_amp", + "intermediate_detuning", + "blue_shift_coeff", + "red_shift_coeff", + ]: value = getattr(self, param) if value <= 0.0: raise ValueError( @@ -182,9 +198,42 @@ def __post_init__(self) -> None: f" enumeration, not {self.limiting_beam}." ) + @property + def _switching_beams_combos(self) -> list[tuple[RydbergBeam, ...]]: + switching_beams: list[tuple[RydbergBeam, ...]] = [ + (beam,) for beam in self.controlled_beams + ] + if len(self.controlled_beams) > 1 and self.multiple_beam_control: + switching_beams.append(tuple(RydbergBeam)) + return switching_beams + + @overload def calculate_detuning_off( - self, amp_on: float, detuning_on: float, optimal_detuning_off: float + self, + amp_on: float, + detuning_on: float, + optimal_detuning_off: float, + return_switching_beams: Literal[False], ) -> float: + pass + + @overload + def calculate_detuning_off( + self, + amp_on: float, + detuning_on: float, + optimal_detuning_off: float, + return_switching_beams: Literal[True], + ) -> tuple[float, tuple[RydbergBeam, ...]]: + pass + + def calculate_detuning_off( + self, + amp_on: float, + detuning_on: float, + optimal_detuning_off: float, + return_switching_beams: bool = False, + ) -> float | tuple[float, tuple[RydbergBeam, ...]]: """Calculates the detuning when the amplitude is off in EOM mode. Args: @@ -193,13 +242,20 @@ def calculate_detuning_off( 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. + return_switching_beams: Whether to return the beams that switch on + on and off. """ off_options = self.detuning_off_options(amp_on, detuning_on) closest_option = np.abs(off_options - optimal_detuning_off).argmin() - return cast(float, off_options[closest_option]) + best_det_off = cast(float, off_options[closest_option]) + if not return_switching_beams: + return best_det_off + return best_det_off, self._switching_beams_combos[closest_option] def detuning_off_options( - self, rabi_frequency: float, detuning_on: float + self, + rabi_frequency: float, + detuning_on: float, ) -> np.ndarray: """Calculates the possible detuning values when the amplitude is off. @@ -216,24 +272,12 @@ def detuning_off_options( # 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 - ] - if self.multiple_beam_control: - # Case where both beams are off ie (OFF, OFF) -> no lightshift - lightshifts.append(0.0) + all_beams: set[RydbergBeam] = set(RydbergBeam) + lightshifts = [] + for beams_off in self._switching_beams_combos: + # The beams that don't switch off contribute to the lightshift + beams_on: set[RydbergBeam] = all_beams - set(beams_off) + lightshifts.append(self._lightshift(rabi_frequency, *beams_on)) # We sum the offset to all lightshifts to get the effective detuning return np.array(lightshifts) + offset @@ -243,7 +287,10 @@ def _lightshift( ) -> 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} + bias = { + RydbergBeam.RED: -self.red_shift_coeff, + RydbergBeam.BLUE: self.blue_shift_coeff, + } # beam off -> beam_rabi_freq = 0 return sum(bias[beam] * rabi_freqs[beam] ** 2 for beam in beams_on) / ( 4 * self.intermediate_detuning @@ -252,16 +299,25 @@ def _lightshift( def _rabi_freq_per_beam( self, rabi_frequency: float ) -> dict[RydbergBeam, float]: + shift_factor = np.sqrt( + self.red_shift_coeff / self.blue_shift_coeff + if self.limiting_beam == RydbergBeam.RED + else self.blue_shift_coeff / self.red_shift_coeff + ) # rabi_freq = (rabi_red * rabi_blue) / (2 * int_detuning) - limit_rabi_freq = self.max_limiting_amp**2 / ( - 2 * self.intermediate_detuning + limit_rabi_freq = ( + shift_factor + * 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 + # below which the lightshift can be zero 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} + base_amp_squared = 2 * rabi_frequency * self.intermediate_detuning + return { + self.limiting_beam: np.sqrt(base_amp_squared / shift_factor), + ~self.limiting_beam: np.sqrt(base_amp_squared * shift_factor), + } # The limiting beam is at its maximum amplitude while the other # has the necessary amplitude to reach the desired effective rabi freq diff --git a/pulser-core/pulser/devices/_devices.py b/pulser-core/pulser/devices/_devices.py index 756512a2b..3a0881fe3 100644 --- a/pulser-core/pulser/devices/_devices.py +++ b/pulser-core/pulser/devices/_devices.py @@ -80,7 +80,9 @@ max_radial_distance=35, min_atom_distance=5, max_sequence_duration=4000, - # TODO: Define max_runs + max_runs=2000, + requires_layout=True, + accepts_new_layouts=False, channel_objects=( Rydberg.Global( max_abs_detuning=2 * np.pi * 20, diff --git a/pulser-core/pulser/json/abstract_repr/deserializer.py b/pulser-core/pulser/json/abstract_repr/deserializer.py index b4d77389a..e76f1a900 100644 --- a/pulser-core/pulser/json/abstract_repr/deserializer.py +++ b/pulser-core/pulser/json/abstract_repr/deserializer.py @@ -16,7 +16,7 @@ import dataclasses import json -from typing import TYPE_CHECKING, Any, Type, Union, cast, overload +from typing import TYPE_CHECKING, Any, Literal, Type, Union, cast, overload import jsonschema import jsonschema.exceptions @@ -57,7 +57,7 @@ if TYPE_CHECKING: from pulser.noise_model import NoiseModel - from pulser.register.base_register import BaseRegister + from pulser.register import Register, Register3D from pulser.sequence import Sequence @@ -263,6 +263,19 @@ def _deserialize_operation(seq: Sequence, op: dict, vars: dict) -> None: post_phase_shift=post_phase_shift, ) + seq.add( + pulse=pulse, + channel=op["channel"], + protocol=op["protocol"], + ) + elif op["op"] == "pulse_arbitrary_phase": + pulse = Pulse.ArbitraryPhase( + amplitude=_deserialize_waveform(op["amplitude"], vars), + phase=_deserialize_waveform(op["phase"], vars), + post_phase_shift=_deserialize_parameter( + op["post_phase_shift"], vars + ), + ) seq.add( pulse=pulse, channel=op["channel"], @@ -371,7 +384,7 @@ def _deserialize_layout(layout_obj: dict[str, Any]) -> RegisterLayout: def _deserialize_register( qubits: list[dict[str, Any]], layout: RegisterLayout | None -) -> BaseRegister: +) -> Register: coords = [(q["x"], q["y"]) for q in qubits] qubit_ids = [q["name"] for q in qubits] if layout: @@ -379,7 +392,20 @@ def _deserialize_register( reg = layout.define_register(*trap_ids, qubit_ids=qubit_ids) else: reg = pulser.Register(dict(zip(qubit_ids, coords))) - return reg + return cast(pulser.Register, reg) + + +def _deserialize_register3d( + qubits: list[dict[str, Any]], layout: RegisterLayout | None +) -> Register3D: + coords = [(q["x"], q["y"], q["z"]) for q in qubits] + qubit_ids = [q["name"] for q in qubits] + if layout: + trap_ids = layout.get_traps_from_coordinates(*coords) + reg = layout.define_register(*trap_ids, qubit_ids=qubit_ids) + else: + reg = pulser.Register3D(dict(zip(qubit_ids, coords))) + return cast(pulser.Register3D, reg) def _deserialize_noise_model(noise_model_obj: dict[str, Any]) -> NoiseModel: @@ -482,11 +508,14 @@ def deserialize_abstract_sequence(obj_str: str) -> Sequence: layout = _deserialize_layout(obj["layout"]) if "layout" in obj else None # Register - reg: Union[BaseRegister, MappableRegister] + reg: Register | Register3D | MappableRegister qubits = obj["register"] if {"name", "x", "y"} == qubits[0].keys(): - # Regular register + # Regular 2D register reg = _deserialize_register(qubits, layout) + elif {"name", "x", "y", "z"} == qubits[0].keys(): + # Regular 3D register + reg = _deserialize_register3d(qubits, layout) else: # Mappable register assert ( @@ -576,20 +605,59 @@ def deserialize_abstract_layout(obj_str: str) -> RegisterLayout: return _deserialize_layout(json.loads(obj_str)) -def deserialize_abstract_register(obj_str: str) -> BaseRegister: +@overload +def deserialize_abstract_register( + obj_str: str, expected_dim: Literal[2] +) -> Register: + pass + + +@overload +def deserialize_abstract_register( + obj_str: str, expected_dim: Literal[3] +) -> Register3D: + pass + + +@overload +def deserialize_abstract_register(obj_str: str) -> Register | Register3D: + pass + + +def deserialize_abstract_register( + obj_str: str, expected_dim: Literal[None, 2, 3] = None +) -> Register | Register3D: """Deserialize a register from an abstract JSON object. Args: - obj_str: the JSON string representing the register encoded + obj_str: The JSON string representing the register encoded in the abstract JSON format. + expected_dim: If defined, ensures the register is of the + specified dimensionality. Returns: The Register instance. """ + if expected_dim not in (None, 2, 3): + raise ValueError( + "When specified, 'expected_dim' must be 2 or 3, " + f"not {expected_dim!s}." + ) validate_abstract_repr(obj_str, "register") obj = json.loads(obj_str) layout = _deserialize_layout(obj["layout"]) if "layout" in obj else None - return _deserialize_register(qubits=obj["register"], layout=layout) + qubits = obj["register"] + dim_ = len(set(qubits[0]) - {"name"}) + # These conditions should be enforced by the schema + assert dim_ == 2 or dim_ == 3 + assert layout is None or layout.dimensionality == dim_ + if expected_dim is not None and expected_dim != dim_: + raise ValueError( + f"The provided register must be in {expected_dim}D, not {dim_}D." + ) + if dim_ == 3: + return _deserialize_register3d(qubits=qubits, layout=layout) + return _deserialize_register(qubits=qubits, layout=layout) def deserialize_abstract_noise_model(obj_str: str) -> NoiseModel: diff --git a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json index 8c8370da3..b70192f2a 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json @@ -377,6 +377,10 @@ { "additionalProperties": false, "properties": { + "blue_shift_coeff": { + "description": "The weight coefficient of the blue beam's contribution to the lightshift.", + "type": "number" + }, "controlled_beams": { "description": "The beams that can be switched on/off with an EOM.", "items": { @@ -415,6 +419,10 @@ "multiple_beam_control": { "description": "Whether both EOMs can be used simultaneously or not.", "type": "boolean" + }, + "red_shift_coeff": { + "description": "The weight coefficient of the red beam's contribution to the lightshift.", + "type": "number" } }, "required": [ @@ -703,6 +711,10 @@ { "additionalProperties": false, "properties": { + "blue_shift_coeff": { + "description": "The weight coefficient of the blue beam's contribution to the lightshift.", + "type": "number" + }, "controlled_beams": { "description": "The beams that can be switched on/off with an EOM.", "items": { @@ -741,6 +753,10 @@ "multiple_beam_control": { "description": "Whether both EOMs can be used simultaneously or not.", "type": "boolean" + }, + "red_shift_coeff": { + "description": "The weight coefficient of the red beam's contribution to the lightshift.", + "type": "number" } }, "required": [ @@ -1043,6 +1059,10 @@ { "additionalProperties": false, "properties": { + "blue_shift_coeff": { + "description": "The weight coefficient of the blue beam's contribution to the lightshift.", + "type": "number" + }, "controlled_beams": { "description": "The beams that can be switched on/off with an EOM.", "items": { @@ -1081,6 +1101,10 @@ "multiple_beam_control": { "description": "Whether both EOMs can be used simultaneously or not.", "type": "boolean" + }, + "red_shift_coeff": { + "description": "The weight coefficient of the red beam's contribution to the lightshift.", + "type": "number" } }, "required": [ @@ -1342,6 +1366,10 @@ { "additionalProperties": false, "properties": { + "blue_shift_coeff": { + "description": "The weight coefficient of the blue beam's contribution to the lightshift.", + "type": "number" + }, "controlled_beams": { "description": "The beams that can be switched on/off with an EOM.", "items": { @@ -1380,6 +1408,10 @@ "multiple_beam_control": { "description": "Whether both EOMs can be used simultaneously or not.", "type": "boolean" + }, + "red_shift_coeff": { + "description": "The weight coefficient of the red beam's contribution to the lightshift.", + "type": "number" } }, "required": [ diff --git a/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json index 500576f77..01899461f 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json @@ -4,8 +4,18 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "Layout": { + "anyOf": [ + { + "$ref": "#/definitions/Layout2D" + }, + { + "$ref": "#/definitions/Layout3D" + } + ], + "description": "Layout with the positions of the traps. A selection of up to 50% of these traps makes up the Register." + }, + "Layout2D": { "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.", @@ -28,6 +38,31 @@ "coordinates" ], "type": "object" + }, + "Layout3D": { + "additionalProperties": false, + "properties": { + "coordinates": { + "description": "The trap coordinates in µm.", + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "type": "array" + }, + "slug": { + "description": "An optional name for the layout.", + "type": "string" + } + }, + "required": [ + "coordinates" + ], + "type": "object" } } } diff --git a/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json index 0595f5f81..f67e41fea 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json @@ -26,15 +26,103 @@ ], "type": "object" }, + "Atom3D": { + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/definitions/QubitId", + "description": "Name of the atom." + }, + "x": { + "description": "x-position in µm", + "type": "number" + }, + "y": { + "description": "y-position in µm", + "type": "number" + }, + "z": { + "description": "z-position in µm", + "type": "number" + } + }, + "required": [ + "name", + "x", + "y", + "z" + ], + "type": "object" + }, + "Layout2D": { + "additionalProperties": false, + "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" + }, + "Layout3D": { + "additionalProperties": false, + "properties": { + "coordinates": { + "description": "The trap coordinates in µm.", + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "type": "array" + }, + "slug": { + "description": "An optional name for the layout.", + "type": "string" + } + }, + "required": [ + "coordinates" + ], + "type": "object" + }, "QubitId": { "description": "Name for a qubit.", "type": "string" }, "Register": { + "anyOf": [ + { + "$ref": "#/definitions/Register2D" + }, + { + "$ref": "#/definitions/Register3D" + } + ] + }, + "Register2D": { "additionalProperties": false, "properties": { "layout": { - "$ref": "layout-schema.json", + "$ref": "#/definitions/Layout2D", "description": "The trap layout underlying the register." }, "register": { @@ -49,6 +137,26 @@ "register" ], "type": "object" + }, + "Register3D": { + "additionalProperties": false, + "properties": { + "layout": { + "$ref": "#/definitions/Layout3D", + "description": "The trap layout underlying the register." + }, + "register": { + "description": "A 3D register containing a set of atoms.", + "items": { + "$ref": "#/definitions/Atom3D" + }, + "type": "array" + } + }, + "required": [ + "register" + ], + "type": "object" } } } diff --git a/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json index 01509615e..48838461a 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json @@ -26,6 +26,34 @@ ], "type": "object" }, + "Atom3D": { + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/definitions/QubitId", + "description": "Name of the atom." + }, + "x": { + "description": "x-position in µm", + "type": "number" + }, + "y": { + "description": "y-position in µm", + "type": "number" + }, + "z": { + "description": "z-position in µm", + "type": "number" + } + }, + "required": [ + "name", + "x", + "y", + "z" + ], + "type": "object" + }, "Basis": { "description": "The two-level-system basis addressable by a given channel.", "enum": [ @@ -352,6 +380,56 @@ ], "type": "object" }, + "Layout2D": { + "additionalProperties": false, + "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" + }, + "Layout3D": { + "additionalProperties": false, + "properties": { + "coordinates": { + "description": "The trap coordinates in µm.", + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "type": "array" + }, + "slug": { + "description": "An optional name for the layout.", + "type": "string" + } + }, + "required": [ + "coordinates" + ], + "type": "object" + }, "MappableQubit": { "additionalProperties": false, "properties": { @@ -690,6 +768,49 @@ ], "type": "object" }, + "OpPulseArbitraryPhase": { + "additionalProperties": false, + "properties": { + "amplitude": { + "$ref": "#/definitions/Waveform", + "description": "Pulse amplitude waveform (in rad/µs)" + }, + "channel": { + "$ref": "#/definitions/ChannelName", + "description": "Device channel to use for this pulse." + }, + "op": { + "const": "pulse_arbitrary_phase", + "type": "string" + }, + "phase": { + "$ref": "#/definitions/Waveform", + "description": "The pulse phase waveform (in radians)" + }, + "post_phase_shift": { + "$ref": "#/definitions/ParametrizedNum", + "description": "A phase shift (in radians) immediately after the end of the pulse" + }, + "protocol": { + "description": "A parametrized pulse constructed with an arbitrary phase waveform.", + "enum": [ + "min-delay", + "no-delay", + "wait-for-all" + ], + "type": "string" + } + }, + "required": [ + "op", + "protocol", + "channel", + "amplitude", + "phase", + "post_phase_shift" + ], + "type": "object" + }, "OpTarget": { "additionalProperties": false, "description": "Adds a waveform to the pulse.", @@ -735,6 +856,9 @@ { "$ref": "#/definitions/OpPulse" }, + { + "$ref": "#/definitions/OpPulseArbitraryPhase" + }, { "$ref": "#/definitions/OpPhaseShift" }, @@ -814,7 +938,7 @@ "description": "A valid device in which to execute the Sequence" }, "layout": { - "$ref": "layout-schema.json", + "$ref": "#/definitions/Layout2D", "description": "The trap layout underlying the register." }, "magnetic_field": { @@ -886,6 +1010,103 @@ ], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "channels": { + "additionalProperties": { + "$ref": "#/definitions/ChannelId" + }, + "description": "Channels declared in this Sequence.", + "type": "object" + }, + "device": { + "anyOf": [ + { + "$ref": "#/definitions/HardcodedDevice" + }, + { + "$ref": "device-schema.json" + } + ], + "description": "A valid device in which to execute the Sequence" + }, + "layout": { + "$ref": "#/definitions/Layout3D", + "description": "The trap layout underlying the register." + }, + "magnetic_field": { + "description": "The magnetic field components in x, y and z (in Gauss)", + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "measurement": { + "anyOf": [ + { + "$ref": "#/definitions/Basis" + }, + { + "type": "null" + } + ], + "description": "Type of measurement to perform after all pulses are executed" + }, + "name": { + "description": "User-assigned sequence name. Can be autogenerated on export if not provided.", + "type": "string" + }, + "operations": { + "description": "Sequence of pulses, delays and target changes, performed in specified order.", + "items": { + "$ref": "#/definitions/Operation" + }, + "type": "array" + }, + "register": { + "description": "A 3D register containing a set of atoms.", + "items": { + "$ref": "#/definitions/Atom3D" + }, + "type": "array" + }, + "slm_mask_targets": { + "description": "The qubits to mask during the first global pulse of the sequence.", + "items": { + "$ref": "#/definitions/QubitId" + }, + "type": "array" + }, + "variables": { + "additionalProperties": { + "$ref": "#/definitions/Variable" + }, + "description": "Variables and expressions that can be used in expressions or parametrized values.", + "type": "object" + }, + "version": { + "const": "1", + "type": "string" + } + }, + "required": [ + "channels", + "device", + "measurement", + "name", + "operations", + "register", + "variables", + "version" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/pulser-core/pulser/json/abstract_repr/serializer.py b/pulser-core/pulser/json/abstract_repr/serializer.py index c2bc412e2..925bc6180 100644 --- a/pulser-core/pulser/json/abstract_repr/serializer.py +++ b/pulser-core/pulser/json/abstract_repr/serializer.py @@ -50,7 +50,7 @@ def default(self, o: Any) -> dict[str, Any] | list | int: return list(o) elif isinstance(o, complex): return dict(real=o.real, imag=o.imag) - else: + else: # pragma: no cover return cast(dict, json.JSONEncoder.default(self, o)) @@ -104,6 +104,7 @@ def serialize_abstract_sequence( seq: Sequence, seq_name: str = "pulser-exported", json_dumps_options: dict[str, Any] = {}, + skip_validation: bool = False, **defaults: Any, ) -> str: """Serializes the Sequence into an abstract JSON object. @@ -114,6 +115,13 @@ def serialize_abstract_sequence( json_dumps_options: A mapping between optional parameters of ``json.dumps()`` (as string) and their value (parameter cannot be "cls"). + skip_validation: Whether to skip the validation of the serialized + sequence against the abstract representation's JSON schema. + Skipping the validation is useful to cut down on execution + time, as this step takes significantly longer than the + serialization itself; it is also low risk, as the validation + is only defensively checking that there are no bugs in the + serialized sequence. defaults: The default 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. @@ -296,7 +304,10 @@ def remove_kwarg_if_default( "channel": data["channel"], "protocol": data["protocol"], } - op_dict.update(data["pulse"]._to_abstract_repr()) + pulse_abstract_repr = data["pulse"]._to_abstract_repr() + if "detuning" not in pulse_abstract_repr: + op_dict["op"] = "pulse_arbitrary_phase" + op_dict.update(pulse_abstract_repr) operations.append(op_dict) elif "phase_shift" in call.name: targets = call.args[1:] @@ -378,5 +389,6 @@ def remove_kwarg_if_default( abstr_seq_str = json.dumps( res, cls=AbstractReprEncoder, **json_dumps_options ) - validate_abstract_repr(abstr_seq_str, "sequence") + if not skip_validation: + validate_abstract_repr(abstr_seq_str, "sequence") return abstr_seq_str diff --git a/pulser-core/pulser/json/abstract_repr/signatures.py b/pulser-core/pulser/json/abstract_repr/signatures.py index fc03b0e7c..66c5510c8 100644 --- a/pulser-core/pulser/json/abstract_repr/signatures.py +++ b/pulser-core/pulser/json/abstract_repr/signatures.py @@ -81,6 +81,9 @@ def all_pos_args(self) -> tuple[str, ...]: "Pulse": PulserSignature( pos=("amplitude", "detuning", "phase"), keyword=("post_phase_shift",) ), + "Pulse.ArbitraryPhase": PulserSignature( + pos=("amplitude", "phase"), keyword=("post_phase_shift",) + ), # Special case operators "truediv": PulserSignature( pos=("lhs", "rhs"), extra=dict(expression="div") diff --git a/pulser-core/pulser/noise_model.py b/pulser-core/pulser/noise_model.py index 53585344c..674518132 100644 --- a/pulser-core/pulser/noise_model.py +++ b/pulser-core/pulser/noise_model.py @@ -82,9 +82,13 @@ class NoiseModel: - "SPAM": SPAM errors. Parametrized by `state_prep_error`, `p_false_pos` and `p_false_neg`. - runs: Number of runs needed (each run draws a new random noise). - samples_per_run: Number of samples per noisy run. Useful for - cutting down on computing time, but unrealistic. + runs: When reconstructing the Hamiltonian from random noise is + necessary, this determines how many times that happens. Not + to be confused with the number of times the resulting + bitstring distribution is sampled when calculating bitstring + counts. + samples_per_run: Number of samples per noisy Hamiltonian. Useful + for cutting down on computing time, but unrealistic. state_prep_error: The state preparation error probability. p_false_pos: Probability of measuring a false positive. p_false_neg: Probability of measuring a false negative. diff --git a/pulser-core/pulser/parametrized/paramobj.py b/pulser-core/pulser/parametrized/paramobj.py index a1fa4bc82..0815fd00a 100644 --- a/pulser-core/pulser/parametrized/paramobj.py +++ b/pulser-core/pulser/parametrized/paramobj.py @@ -252,7 +252,11 @@ def _to_abstract_repr(self) -> dict[str, Any]: cls_name = self.args[0].__name__ name = f"{cls_name}.{op_name}" signature = SIGNATURES[ - "Pulse" if cls_name == "Pulse" else name + ( + "Pulse" + if cls_name == "Pulse" and op_name != "ArbitraryPhase" + else name + ) ] # No existing classmethod has *args in its signature assert ( diff --git a/pulser-core/pulser/pulse.py b/pulser-core/pulser/pulse.py index d02fa97b5..7a94c4814 100644 --- a/pulser-core/pulser/pulse.py +++ b/pulser-core/pulser/pulse.py @@ -28,7 +28,12 @@ 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 +from pulser.waveforms import ( + ConstantWaveform, + CustomWaveform, + RampWaveform, + Waveform, +) if TYPE_CHECKING: from pulser.channels.base_channel import Channel @@ -50,6 +55,8 @@ class Pulse: If either quantity is constant throughout the entire pulse, use the ``ConstantDetuning``, ``ConstantAmplitude`` or ``ConstantPulse`` class method to create it. + If defining the pulse's phase modulation is preferred over its frequency + modulation, use ``Pulse.ArbitraryPhase()``. Note: We define the ``amplitude`` of a pulse to be its Rabi frequency, @@ -57,11 +64,11 @@ class Pulse: :math:`\delta`, also in rad/µs. Args: - amplitude: The pulse amplitude waveform. - detuning: The pulse detuning waveform. + amplitude: The pulse amplitude waveform (in rad/µs). + detuning: The pulse detuning waveform (in rad/µs). 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. This allows + shift(in rad) immediately after the end of the pulse. This allows for enconding of arbitrary single-qubit gates into a single pulse (see ``Sequence.phase_shift()`` for more information). """ @@ -127,11 +134,11 @@ def ConstantDetuning( """Creates a Pulse with an amplitude waveform and a constant detuning. Args: - amplitude: The pulse amplitude waveform. + amplitude: The pulse amplitude waveform (in rad/µs). detuning: The detuning value (in rad/µs). 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. + phase shift (in rad) immediately after the end of the pulse. """ detuning_wf = ConstantWaveform( cast(Waveform, amplitude).duration, detuning @@ -151,10 +158,10 @@ def ConstantAmplitude( Args: amplitude: The pulse amplitude value (in rad/µs). - detuning: The pulse detuning waveform. + detuning: The pulse detuning waveform (in rad/µs). 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. + phase shift (in rad) immediately after the end of the pulse. """ amplitude_wf = ConstantWaveform( cast(Waveform, detuning).duration, amplitude @@ -178,12 +185,68 @@ def ConstantPulse( detuning: The detuning value (in rad/µs). 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. + phase shift (in rad) immediately after the end of the pulse. """ amplitude_wf = ConstantWaveform(duration, amplitude) detuning_wf = ConstantWaveform(duration, detuning) return cls(amplitude_wf, detuning_wf, phase, post_phase_shift) + @classmethod + @parametrize + def ArbitraryPhase( + cls, + amplitude: Waveform | Parametrized, + phase: Waveform | Parametrized, + post_phase_shift: float | Parametrized = 0.0, + ) -> Pulse: + r"""Pulse with an arbitrary phase waveform. + + Args: + amplitude: The amplitude waveform (in rad/µs). + phase: The phase waveform (in rad). + post_phase_shift: Optionally lets you add a + phase shift (in rad) immediately after the end of the pulse. + + Note: + Due to how the Hamiltonian is defined in Pulser, the phase and + detuning are related by + + .. math:: \phi(t) = \phi_c - \sum_{k=0}^{t} \delta(k) + + where :math:`\phi_c` is the pulse's constant phase offset. + From a given phase waveform, we extract the phase offset and + detuning waveform that respect this formula for every sample of + :math:`\phi(t)` and use these quantities to define the Pulse. + + Warning: + Except when the phase waveform is a ``ConstantWaveform`` or a + ``RampWaveform``, the extracted detuning waveform will be a + ``CustomWaveform``. This makes the Pulse uncapable of automatically + extending its duration to fit a channel's clock period. + + Returns: + A regular Pulse, with the phase waveform translated into a + detuning waveform and a constant phase offset. + """ + if not isinstance(phase, Waveform): + raise TypeError( + f"'phase' must be a waveform, not of type {type(phase)}." + ) + detuning: Waveform + if isinstance(phase, ConstantWaveform): + detuning = ConstantWaveform(phase.duration, 0.0) + elif isinstance(phase, RampWaveform): + detuning = ConstantWaveform(phase.duration, -phase.slope * 1e3) + else: + detuning_samples = -np.diff(phase.samples) * 1e3 # rad/ns->rad/µs + # Use the same value in the first two detuning samples + detuning = CustomWaveform( + np.pad(detuning_samples, (1, 0), mode="edge") + ) + # Adjust phase_c to incorporate the first detuning sample + phase_c = phase.first_value + detuning.first_value * 1e-3 + return cls(amplitude, detuning, phase_c, post_phase_shift) + def draw(self) -> None: """Draws the pulse's amplitude and frequency waveforms.""" fig, ax1 = plt.subplots() @@ -254,15 +317,17 @@ def _to_abstract_repr(self) -> dict[str, Any]: def __str__(self) -> str: return ( - f"Pulse(Amp={self.amplitude!s}, Detuning={self.detuning!s}, " + f"Pulse(Amp={self.amplitude!s} rad/µs, " + f"Detuning={self.detuning!s} rad/µs, " f"Phase={self.phase:.3g})" ) def __repr__(self) -> str: return ( - f"Pulse(amp={self.amplitude!r}, detuning={self.detuning!r}, " - + f"phase={self.phase:.3g}, " - + f"post_phase_shift={self.post_phase_shift:.3g})" + f"Pulse(amp={self.amplitude!r} rad/µs, " + f"detuning={self.detuning!r} rad/µs, " + f"phase={self.phase:.3g}, " + f"post_phase_shift={self.post_phase_shift:.3g})" ) def __eq__(self, other: Any) -> bool: diff --git a/pulser-core/pulser/register/base_register.py b/pulser-core/pulser/register/base_register.py index c49ed7432..eb03c597f 100644 --- a/pulser-core/pulser/register/base_register.py +++ b/pulser-core/pulser/register/base_register.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json from abc import ABC, abstractmethod from collections.abc import Iterable, Mapping from collections.abc import Sequence as abcSequence @@ -32,6 +33,8 @@ import numpy as np from numpy.typing import ArrayLike +from pulser.json.abstract_repr.serializer import AbstractReprEncoder +from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.utils import obj_to_dict from pulser.register._coordinates import CoordsCollection from pulser.register.weight_maps import DetuningMap @@ -289,3 +292,16 @@ def coords_hex_hash(self) -> str: the '0x' prefix (unlike what is returned by 'hex()'). """ return self._safe_hash().hex() + + @abstractmethod + def _to_abstract_repr(self) -> list[dict[str, Union[QubitId, float]]]: + pass + + def to_abstract_repr(self) -> str: + """Serializes the register into an abstract JSON object.""" + abstr_reg: dict[str, Any] = dict(register=self._to_abstract_repr()) + if self.layout is not None: + abstr_reg["layout"] = self.layout + abstr_reg_str = json.dumps(abstr_reg, cls=AbstractReprEncoder) + validate_abstract_repr(abstr_reg_str, "register") + return abstr_reg_str diff --git a/pulser-core/pulser/register/register.py b/pulser-core/pulser/register/register.py index 260608e0b..db6abd4c0 100644 --- a/pulser-core/pulser/register/register.py +++ b/pulser-core/pulser/register/register.py @@ -15,10 +15,9 @@ from __future__ import annotations -import json import warnings from collections.abc import Mapping -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Union import matplotlib.pyplot as plt import numpy as np @@ -30,8 +29,6 @@ from pulser.json.abstract_repr.deserializer import ( deserialize_abstract_register, ) -from pulser.json.abstract_repr.serializer import AbstractReprEncoder -from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.utils import stringify_qubit_ids from pulser.register._reg_drawer import RegDrawer from pulser.register.base_register import BaseRegister, QubitId @@ -422,15 +419,6 @@ def _to_abstract_repr(self) -> list[dict[str, Union[QubitId, float]]]: for name, (x, y) in zip(names, self._coords) ] - def to_abstract_repr(self) -> str: - """Serializes the register into an abstract JSON object.""" - abstr_reg: dict[str, Any] = dict(register=self._to_abstract_repr()) - if self.layout is not None: - abstr_reg["layout"] = self.layout - abstr_reg_str = json.dumps(abstr_reg, cls=AbstractReprEncoder) - validate_abstract_repr(abstr_reg_str, "register") - return abstr_reg_str - @staticmethod def from_abstract_repr(obj_str: str) -> Register: """Deserialize a register from an abstract JSON object. @@ -444,4 +432,4 @@ def from_abstract_repr(obj_str: str) -> Register: "The serialized register must be given as a string. " f"Instead, got object of type {type(obj_str)}." ) - return cast(Register, deserialize_abstract_register(obj_str)) + return deserialize_abstract_register(obj_str, expected_dim=2) diff --git a/pulser-core/pulser/register/register3d.py b/pulser-core/pulser/register/register3d.py index 29ed78b18..831c64b75 100644 --- a/pulser-core/pulser/register/register3d.py +++ b/pulser-core/pulser/register/register3d.py @@ -22,6 +22,10 @@ import numpy as np from numpy.typing import ArrayLike +from pulser.json.abstract_repr.deserializer import ( + deserialize_abstract_register, +) +from pulser.json.utils import stringify_qubit_ids from pulser.register._reg_drawer import RegDrawer from pulser.register.base_register import BaseRegister, QubitId from pulser.register.register import Register @@ -240,3 +244,25 @@ def draw( def _to_dict(self) -> dict[str, Any]: return super()._to_dict() + + def _to_abstract_repr(self) -> list[dict[str, QubitId | float]]: + names = stringify_qubit_ids(self._ids) + return [ + {"name": name, "x": x, "y": y, "z": z} + for name, (x, y, z) in zip(names, self._coords) + ] + + @staticmethod + def from_abstract_repr(obj_str: str) -> Register3D: + """Deserialize a 3D register from an abstract JSON object. + + Args: + obj_str (str): the JSON string representing the register encoded + in the abstract JSON format. + """ + if not isinstance(obj_str, str): + raise TypeError( + "The serialized register must be given as a string. " + f"Instead, got object of type {type(obj_str)}." + ) + return deserialize_abstract_register(obj_str, expected_dim=3) diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index e88e3c676..e62121bf5 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -98,9 +98,15 @@ class ChannelSamples: eom_start_buffers: list[tuple[int, int]] = field(default_factory=list) eom_end_buffers: list[tuple[int, int]] = field(default_factory=list) target_time_slots: list[_TimeSlot] = field(default_factory=list) + _centered_phase: np.ndarray | None = None def __post_init__(self) -> None: - assert len(self.amp) == len(self.det) == len(self.phase) + assert ( + len(self.amp) + == len(self.det) + == len(self.phase) + == len(self.centered_phase) + ) self.duration = len(self.amp) for t in self.slots: @@ -117,6 +123,28 @@ def initial_targets(self) -> set[QubitId]: else set() ) + @property + def centered_phase(self) -> np.ndarray: + """The phase samples centered in ]-π, π].""" + if self._centered_phase is not None: + return self._centered_phase + phase_ = self.phase.copy() % (2 * np.pi) + phase_[phase_ > np.pi] -= 2 * np.pi + return phase_ + + @property + def phase_modulation(self) -> np.ndarray: + r"""The phase modulation samples (in rad). + + Constructed by combining the integral of the detuning samples with the + phase offset samples according to + + .. math:: \phi(t) = \phi_c(t) - \sum_{k=0}^{t} \delta(k) + """ + return cast( + np.ndarray, self.centered_phase - np.cumsum(self.det * 1e-3) + ) + def extend_duration(self, new_duration: int) -> ChannelSamples: """Extends the duration of the samples. @@ -151,7 +179,21 @@ def extend_duration(self, new_duration: int) -> ChannelSamples: (0, extension), mode="edge" if self.phase.size > 0 else "constant", ) - return replace(self, amp=new_amp, det=new_detuning, phase=new_phase) + _new_centered_phase = None + if self._centered_phase is not None: + _new_centered_phase = np.pad( + self._centered_phase, + (0, extension), + mode="edge" if self._centered_phase.size > 0 else "constant", + ) + + return replace( + self, + amp=new_amp, + det=new_detuning, + phase=new_phase, + _centered_phase=_new_centered_phase, + ) def is_empty(self) -> bool: """Whether the channel is effectively empty. @@ -372,6 +414,9 @@ def masked( new_samples["det"] = channel_obj.modulate(self.det, keep_ends=True) new_samples["phase"] = channel_obj.modulate(self.phase, keep_ends=True) + new_samples["_centered_phase"] = channel_obj.modulate( + self.centered_phase, keep_ends=True + ) for key in new_samples: new_samples[key] = new_samples[key][slice(0, max_duration)] return replace(self, **new_samples) diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index 31edf0b12..8f2847ba5 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -23,6 +23,7 @@ from pulser.channels.base_channel import Channel from pulser.channels.dmm import DMM +from pulser.channels.eom import RydbergBeam from pulser.pulse import Pulse from pulser.register.base_register import QubitId from pulser.register.weight_maps import DetuningMap @@ -45,7 +46,8 @@ class _EOMSettings: detuning_on: float detuning_off: float ti: int - tf: Optional[int] = None + tf: int | None = None + switching_beams: tuple[RydbergBeam, ...] = () @dataclass @@ -337,6 +339,7 @@ def enable_eom( amp_on: float, detuning_on: float, detuning_off: float, + switching_beams: tuple[RydbergBeam, ...] = (), _skip_buffer: bool = False, ) -> None: channel_obj = self[channel_id].channel_obj @@ -368,6 +371,7 @@ def enable_eom( detuning_on=detuning_on, detuning_off=detuning_off, ti=self[channel_id][-1].tf, + switching_beams=switching_beams, ) self[channel_id].eom_blocks.append(eom_settings) diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index b7d72fb52..f45f8b0f4 100644 --- a/pulser-core/pulser/sequence/_seq_drawer.py +++ b/pulser-core/pulser/sequence/_seq_drawer.py @@ -115,15 +115,18 @@ class ChannelDrawContent: eom_start_buffers: list[EOMSegment] eom_end_buffers: list[EOMSegment] interp_pts: dict[str, list[list[float]]] = field(default_factory=dict) + phase_modulated: bool = False def __post_init__(self) -> None: - self.samples.phase = self.samples.phase / (2 * np.pi) - self._samples_from_curves = { + self.curves_on = {"amplitude": True, "detuning": False, "phase": False} + + @property + def _samples_from_curves(self) -> dict[str, str]: + return { "amplitude": "amp", "detuning": "det", - "phase": "phase", + "phase": ("phase_modulation" if self.phase_modulated else "phase"), } - self.curves_on = {"amplitude": True, "detuning": False, "phase": False} @property def n_axes_on(self) -> int: @@ -161,14 +164,18 @@ def curves_on_indices(self) -> list[int]: def _give_curves_from_samples( self, samples: ChannelSamples ) -> list[np.ndarray]: - return [ - getattr(samples, self._samples_from_curves[qty]) - for qty in CURVES_ORDER - ] + curves = [] + for qty in CURVES_ORDER: + qty_arr = getattr(samples, self._samples_from_curves[qty]) + if "phase" in qty: + qty_arr = qty_arr / (2 * np.pi) + curves.append(qty_arr) + return curves def gather_data( - sampled_seq: SequenceSamples, shown_duration: Optional[int] = None + sampled_seq: SequenceSamples, + shown_duration: Optional[int] = None, ) -> dict: """Collects the whole sequence data for plotting. @@ -477,6 +484,7 @@ def _draw_channel_content( draw_modulation: bool = False, draw_phase_curve: bool = False, draw_detuning_maps: bool = False, + phase_modulated: bool = False, shown_duration: Optional[int] = None, ) -> tuple[Figure, Any, dict]: """Draws samples of a sequence. @@ -504,6 +512,8 @@ def _draw_channel_content( draw_detuning_maps: Draws the detuning maps applied on the qubits of the register of the sequence. Shown before the pulse sequence, defaults to False. + phase_modulated: Show the phase modulation samples instead of the + detuning and phase offset combination. shown_duration: Total duration to be shown in the X axis. """ @@ -522,9 +532,13 @@ def phase_str(phi: float) -> str: total_duration = data["total_duration"] time_scale = 1e3 if total_duration > 1e4 else 1 for ch in sampled_seq.channels: + data[ch].phase_modulated = phase_modulated if np.count_nonzero(data[ch].samples.det) > 0: - data[ch].curves_on["detuning"] = True - if draw_phase_curve and np.count_nonzero(data[ch].samples.phase) > 0: + data[ch].curves_on["detuning"] = not phase_modulated + data[ch].curves_on["phase"] = phase_modulated + if (phase_modulated or draw_phase_curve) and np.count_nonzero( + data[ch].samples.phase + ) > 0: data[ch].curves_on["phase"] = True # Boxes for qubit and phase text @@ -614,10 +628,16 @@ def phase_str(phi: float) -> str: det_range = det_max - det_min det_top = det_max + det_range * 0.15 det_bottom = det_min - det_range * 0.05 + # Phase limits + phase_min = min(*ref_ys[2], 0.0) + phase_max = max(*ref_ys[2], 1.0 if not phase_modulated else 0.1) + phase_range = phase_max - phase_min + phase_top = phase_max + phase_range * 0.15 + phase_bottom = phase_min - phase_range * 0.05 ax_lims = [ (amp_bottom, amp_top), (det_bottom, det_top), - (min(0.0, *ref_ys[2]), max(1.1, *ref_ys[2])), + (phase_bottom, phase_top), ] ax_lims = [ax_lims[i] for i in ch_data.curves_on_indices()] for ax, ylim in zip(axes, ax_lims): @@ -1170,6 +1190,7 @@ def draw_samples( draw_detuning_maps: bool = False, draw_qubit_amp: bool = False, draw_qubit_det: bool = False, + phase_modulated: bool = False, ) -> tuple[Figure | None, Figure, Figure | None, Figure | None]: """Draws a SequenceSamples. @@ -1195,6 +1216,8 @@ def draw_samples( the drawing of the sequence. draw_qubit_det: Draws the detuning seen by the qubits locally after the drawing of the sequence. + phase_modulated: Show the phase modulation samples instead of the + detuning and phase offset combination. """ if not len(sampled_seq.channels): raise RuntimeError("Can't draw an empty sequence.") @@ -1215,6 +1238,7 @@ def draw_samples( draw_input=True, draw_modulation=False, draw_phase_curve=draw_phase_curve, + phase_modulated=phase_modulated, shown_duration=max_slot_tf, ) (fig_qubit, fig_legend) = _draw_qubit_content( @@ -1242,6 +1266,7 @@ def draw_sequence( draw_detuning_maps: bool = False, draw_qubit_amp: bool = False, draw_qubit_det: bool = False, + phase_modulated: bool = False, ) -> tuple[Figure | None, Figure, Figure | None, Figure | None]: """Draws the entire sequence. @@ -1275,6 +1300,8 @@ def draw_sequence( the drawing of the sequence. draw_qubit_det: Draws the detuning seen by the qubits locally after the drawing of the sequence. + phase_modulated: Show the phase modulation samples instead of the + detuning and phase offset combination. """ # Sample the sequence and get the data to plot shown_duration = seq.get_duration(include_fall_time=draw_modulation) @@ -1296,6 +1323,7 @@ def draw_sequence( draw_modulation, draw_phase_curve, draw_detuning_maps, + phase_modulated, shown_duration, ) draw_output = draw_modulation diff --git a/pulser-core/pulser/sequence/_seq_str.py b/pulser-core/pulser/sequence/_seq_str.py index af578a421..33ddee117 100644 --- a/pulser-core/pulser/sequence/_seq_str.py +++ b/pulser-core/pulser/sequence/_seq_str.py @@ -64,7 +64,7 @@ def seq_to_str(sequence: Sequence) -> str: ts.ti, ts.tf, ( - ts.type.detuning + f"{ts.type.detuning!s} rad/µs" if not seq.is_detuned_delay(ts.type) else "{:.3g} rad/µs".format( cast(float, ts.type.detuning[0]) diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index ae4f88914..c504eb3cb 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -684,6 +684,45 @@ def _config_detuning_map( # DMM has Global addressing self._add_to_schedule(dmm_name, _TimeSlot("target", -1, 0, self._qids)) + def switch_register( + self, new_register: BaseRegister | MappableRegister + ) -> Sequence: + """Replicate the sequence with a different register. + + The new sequence is reconstructed with the provided register by + replicating all the instructions used to build the original sequence. + This means that operations referecing specific qubits IDs + (eg. `Sequence.target()`) expect to find the same qubit IDs in the new + register. By the same token, switching from a register to a mappable + register might fail if one of the instructions does not work with + mappable registers (e.g. `Sequence.configure_slm_mask()`). + + Warns: + UserWarning: If the sequence is configuring a detuning map, a + warning is raised to remind the user that the detuning map is + unchanged and might no longer be aligned with the qubits in + the new register. + + Args: + new_register: The new register to give the sequence. + + Returns: + The sequence with the new register. + """ + new_seq = type(self)(register=new_register, device=self._device) + # Copy the variables to the new sequence + new_seq._variables = self.declared_variables + for call in self._calls[1:] + self._to_build_calls: + if call.name == "config_detuning_map": + warnings.warn( + "Switching the register of a sequence that configures" + " a detuning map. Please ensure that the new qubit" + " positions are still aligned.", + stacklevel=2, + ) + getattr(new_seq, call.name)(*call.args, **call.kwargs) + return new_seq + def switch_device( self, new_device: DeviceType, strict: bool = False ) -> Sequence: @@ -1110,8 +1149,14 @@ def enable_eom_mode( detuning_on = cast(float, detuning_on) eom_config = cast(RydbergEOM, channel_obj.eom_config) if not isinstance(optimal_detuning_off, Parametrized): - detuning_off = eom_config.calculate_detuning_off( - amp_on, detuning_on, optimal_detuning_off + ( + detuning_off, + switching_beams, + ) = eom_config.calculate_detuning_off( + amp_on, + detuning_on, + optimal_detuning_off, + return_switching_beams=True, ) off_pulse = Pulse.ConstantPulse( channel_obj.min_duration, 0.0, detuning_off, 0.0 @@ -1130,7 +1175,7 @@ def enable_eom_mode( ti=self.get_duration(channel, include_fall_time=True), ) self._schedule.enable_eom( - channel, amp_on, detuning_on, detuning_off + channel, amp_on, detuning_on, detuning_off, switching_beams ) if correct_phase_drift: buffer_slot = self._last(channel) @@ -1241,7 +1286,7 @@ def add_eom_pulse( 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) + post_phase_shift: Optionally lets you add a phase shift (in rad) immediately after the end of the pulse. protocol: Stipulates how to deal with eventual conflicts with other channels (see `Sequence.add()` for more details). @@ -1482,7 +1527,7 @@ def phase_shift( Bloch sphere). Args: - phi: The intended phase shift (in rads). + phi: The intended phase shift (in rad). targets: The ids of the qubits to apply the phase shift to. basis: The basis (i.e. electronic transition) to associate the phase shift to. Must correspond to the basis of a declared @@ -1504,7 +1549,7 @@ def phase_shift_index( Bloch sphere). Args: - phi: The intended phase shift (in rads). + phi: The intended phase shift (in rad). targets: The indices of the qubits to apply the phase shift to. A qubit index is a number between 0 and the number of qubits. It is then converted to a Qubit ID using the order in which @@ -1702,6 +1747,7 @@ def to_abstract_repr( self, seq_name: str = "pulser-exported", json_dumps_options: dict[str, Any] = {}, + skip_validation: bool = False, **defaults: Any, ) -> str: """Serializes the Sequence into an abstract JSON object. @@ -1712,6 +1758,13 @@ def to_abstract_repr( json_dumps_options: A mapping between optional parameters of ``json.dumps()`` (as string) and their value (parameter cannot be "cls"). + skip_validation: Whether to skip the validation of the serialized + sequence against the abstract representation's JSON schema. + Skipping the validation is useful to cut down on execution + time, as this step takes significantly longer than the + serialization itself; it is also low risk, as the validation + is only defensively checking that there are no bugs in the + serialized sequence. defaults: The default 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. @@ -1727,7 +1780,11 @@ def to_abstract_repr( """ try: return serialize_abstract_sequence( - self, seq_name, json_dumps_options, **defaults + self, + seq_name=seq_name, + json_dumps_options=json_dumps_options, + skip_validation=skip_validation, + **defaults, ) except jsonschema.exceptions.ValidationError as e: if self.is_parametrized(): @@ -1832,6 +1889,7 @@ def from_abstract_repr(obj_str: str) -> Sequence: def draw( self, mode: str = "input+output", + as_phase_modulated: bool = False, draw_phase_area: bool = False, draw_interp_pts: bool = True, draw_phase_shifts: bool = False, @@ -1852,6 +1910,8 @@ def draw( after modulation. 'input+output' will draw both curves except for channels without a defined modulation bandwidth, in which case only the input is drawn. + as_phase_modulated: Instead of displaying the detuning and phase + offsets, displays the equivalent phase modulation. draw_phase_area: Whether phase and area values need to be shown as text on the plot, defaults to False. Doesn't work in 'output' mode. If `draw_phase_curve=True`, phase values are @@ -1934,6 +1994,7 @@ def draw( draw_detuning_maps=draw_detuning_maps, draw_qubit_amp=draw_qubit_amp, draw_qubit_det=draw_qubit_det, + phase_modulated=as_phase_modulated, ) if fig_name is not None: name, ext = os.path.splitext(fig_name) @@ -2009,7 +2070,9 @@ def _add( ph_refs = { self._basis_ref[basis][q].phase.last_phase for q in last.targets } - if len(ph_refs) != 1: + if isinstance(channel_obj, DMM): + phase_ref = None + elif len(ph_refs) != 1: raise ValueError( "Cannot do a multiple-target pulse on qubits with different " "phase references for the same basis." @@ -2265,6 +2328,8 @@ def _validate_and_adjust_pulse( detuning_map = cast( _DMMSchedule, self._schedule[channel] ).detuning_map + # Ignore the phase reference for DMM + assert phase_ref is None else: # If channel name can't be found among _schedule keys, the # Sequence is parametrized and channel is a dmm_name diff --git a/pulser-core/pulser/waveforms.py b/pulser-core/pulser/waveforms.py index 0a7bdfbb0..4ef560f77 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -124,23 +124,28 @@ def last_value(self) -> float: @property def integral(self) -> float: - """Integral of the waveform (time in ns, value in rad/µs).""" + """Integral of the waveform (in [waveform units].µs).""" return float(np.sum(self.samples)) * 1e-3 # ns * rad/µs = 1e-3 - def draw(self, output_channel: Optional[Channel] = None) -> None: + def draw( + self, + output_channel: Optional[Channel] = None, + ylabel: str | None = None, + ) -> None: """Draws the waveform. Args: output_channel: The output channel. If given, will draw the modulated waveform on top of the input one. + ylabel: An optional label for the y-axis of the plot. """ fig, ax = plt.subplots() if not output_channel: - self._plot(ax, "rad/µs") + self._plot(ax, ylabel=ylabel) else: self._plot( ax, - "rad/µs", + ylabel=ylabel, label="Input", start_t=self.modulation_buffers(output_channel)[0], ) @@ -425,8 +430,8 @@ class CustomWaveform(Waveform): """A custom waveform. Args: - samples: The modulation values at each time step - (in rad/µs). The number of samples dictates the duration, in ns. + samples: The modulation values at each time step. + The number of samples dictates the duration, in ns. """ def __init__(self, samples: ArrayLike): @@ -470,7 +475,7 @@ class ConstantWaveform(Waveform): Args: duration: The waveform duration (in ns). - value: The modulation value (in rad/µs). + value: The value. """ def __init__( @@ -515,13 +520,10 @@ def _to_abstract_repr(self) -> dict[str, Any]: return abstract_repr("ConstantWaveform", self._duration, self._value) def __str__(self) -> str: - return f"{self._value:.3g} rad/µs" + return f"{self._value:.3g}" def __repr__(self) -> str: - return ( - f"ConstantWaveform({self._duration} ns, " - + f"{self._value:.3g} rad/µs)" - ) + return f"ConstantWaveform({self._duration} ns, {self._value:.3g})" def __mul__(self, other: float) -> ConstantWaveform: return ConstantWaveform(self._duration, self._value * float(other)) @@ -532,8 +534,8 @@ class RampWaveform(Waveform): Args: duration: The waveform duration (in ns). - start: The value (in rad/µs) at the initial sample. - stop: The value (in rad/µs) at the final sample. + start: The value at the initial sample. + stop: The value at the final sample. """ def __init__( @@ -565,7 +567,7 @@ def _samples(self) -> np.ndarray: @property def slope(self) -> float: - r"""Slope of the ramp, in :math:`s^{-15}`.""" + r"""Slope of the ramp, in [waveform units] / ns.""" return (self._stop - self._start) / (self._duration - 1) def change_duration(self, new_duration: int) -> RampWaveform: @@ -588,12 +590,12 @@ def _to_abstract_repr(self) -> dict[str, Any]: ) def __str__(self) -> str: - return f"Ramp({self._start:.3g}->{self._stop:.3g} rad/µs)" + return f"Ramp({self._start:.3g}->{self._stop:.3g})" def __repr__(self) -> str: return ( f"RampWaveform({self._duration} ns, " - + f"{self._start:.3g}->{self._stop:.3g} rad/µs)" + + f"{self._start:.3g}->{self._stop:.3g})" ) def __mul__(self, other: float) -> RampWaveform: @@ -604,6 +606,11 @@ def __mul__(self, other: float) -> RampWaveform: class BlackmanWaveform(Waveform): """A Blackman window of a specified duration and area. + Warning: + The BlackmanWaveform assumes its values are in rad/µs for the + area calculation. If this is not the case, the 'area' value should be + scaled accordingly. + Args: duration: The waveform duration (in ns). area: The integral of the waveform. Can be negative, in which @@ -646,6 +653,11 @@ def from_max_val( the maximum value. The duration is chosen so that the maximum value is not surpassed, but approached as closely as possible. + Warning: + The BlackmanWaveform assumes its values are in rad/µs for the + area calculation. If this is not the case, the 'max_val' and 'area' + values should be scaled accordingly. + Args: max_val: The maximum value threshold (in rad/µs). If negative, it is taken as the lower bound i.e. the minimum @@ -736,7 +748,7 @@ class InterpolatedWaveform(Waveform): Args: duration: The waveform duration (in ns). - values: Values of the interpolation points (in rad/µs). + values: Values of the interpolation points. times: Fractions of the total duration (between 0 and 1), indicating where to place each value on the time axis. If not given, the values are spread evenly throughout the full @@ -832,7 +844,7 @@ def interp_function( @property def data_points(self) -> np.ndarray: - """Points (t[ns], value[rad/µs]) that define the interpolation.""" + """Points (t[ns], value[arb. units]) that define the interpolation.""" return self._data_pts.copy() def change_duration(self, new_duration: int) -> InterpolatedWaveform: @@ -908,6 +920,12 @@ class KaiserWaveform(Waveform): check the numpy documentation for the kaiser(M, beta) function: https://numpy.org/doc/stable/reference/generated/numpy.kaiser.html + Warning: + The KaiserWaveform assumes its values are in rad/µs for the + area calculation. If this is not the case, the 'area' + value should be scaled accordingly. + + Args: duration: The waveform duration (in ns). area: The integral of the waveform. Can be negative, @@ -970,6 +988,11 @@ def from_max_val( the maximum value. The duration is chosen so that the maximum value is not surpassed, but approached as closely as possible. + Warning: + The KaiserWaveform assumes its values are in rad/µs for the + area calculation. If this is not the case, the 'max_val' and 'area' + values should be scaled accordingly. + Args: max_val: The maximum value threshold (in rad/µs). If negative, it is taken as the lower bound i.e. the minimum diff --git a/pulser-pasqal/pulser_pasqal/backends.py b/pulser-pasqal/pulser_pasqal/backends.py index 73706511b..adb710335 100644 --- a/pulser-pasqal/pulser_pasqal/backends.py +++ b/pulser-pasqal/pulser_pasqal/backends.py @@ -44,9 +44,10 @@ def __init__( sequence: pulser.Sequence, connection: PasqalCloud, config: EmulatorConfig | None = None, + mimic_qpu: bool = False, ) -> None: """Initializes a new Pasqal emulator backend.""" - super().__init__(sequence, connection) + super().__init__(sequence, connection, mimic_qpu=mimic_qpu) config_ = config or self.default_config self._validate_config(config_) self._config = config_ @@ -81,6 +82,7 @@ def run( suffix = f" when executing a sequence on {self.__class__.__name__}." if not job_params: raise ValueError("'job_params' must be specified" + suffix) + self._type_check_job_params(job_params) if any("runs" not in j for j in job_params): raise ValueError( "All elements of 'job_params' must specify 'runs'" + suffix @@ -92,6 +94,7 @@ def run( emulator=self.emulator, config=self._config, wait=wait, + mimic_qpu=self._mimic_qpu, ) def _validate_config(self, config: EmulatorConfig) -> None: @@ -131,6 +134,8 @@ class EmuTNBackend(PasqalEmulator): connection: An open PasqalCloud connection. config: An EmulatorConfig to configure the backend. If not provided, the default configuration is used. + mimic_qpu: Whether to mimic the validations necessary for + execution on a QPU. """ emulator = pasqal_cloud.EmulatorType.EMU_TN @@ -153,6 +158,8 @@ class EmuFreeBackend(PasqalEmulator): connection: An open PasqalCloud connection. config: An EmulatorConfig to configure the backend. If not provided, the default configuration is used. + mimic_qpu: Whether to mimic the validations necessary for + execution on a QPU. """ emulator = pasqal_cloud.EmulatorType.EMU_FREE diff --git a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py index d821cc014..b4c96397e 100644 --- a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py +++ b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py @@ -30,6 +30,7 @@ from pulser import Sequence from pulser.backend.config import EmulatorConfig +from pulser.backend.qpu import QPUBackend from pulser.backend.remote import ( JobParams, RemoteConnection, @@ -117,25 +118,46 @@ def submit( job_params: list[JobParams] = _make_json_compatible( kwargs.get("job_params", []) ) - if emulator is None: + mimic_qpu: bool = kwargs.get("mimic_qpu", False) + if emulator is None or mimic_qpu: available_devices = self.fetch_available_devices() - # TODO: Could be better to check if the devices are - # compatible, even if not exactly equal - if sequence.device not in available_devices.values(): + available_device_names = { + dev.name: key for key, dev in available_devices.items() + } + err_suffix = ( + " Please fetch the latest devices with " + "`PasqalCloud.fetch_available_devices()` and rebuild " + "the sequence with one of the options." + ) + if (name := sequence.device.name) not in available_device_names: raise ValueError( "The device used in the sequence does not match any " "of the devices currently available through the remote " - "connection." + "connection." + err_suffix ) - # TODO: Validate the register layout - + if sequence.device != ( + new_device := available_devices[available_device_names[name]] + ): + try: + sequence = sequence.switch_device(new_device, strict=True) + except Exception as e: + raise ValueError( + "The sequence is not compatible with the latest " + "device specs." + err_suffix + ) from e + # Validate the sequence with the new device + QPUBackend.validate_sequence(sequence, mimic_qpu=True) + + QPUBackend.validate_job_params(job_params, new_device.max_runs) if sequence.is_parametrized() or sequence.is_register_mappable(): for params in job_params: vars = params.get("variables", {}) sequence.build(**vars) configuration = self._convert_configuration( - config=kwargs.get("config", None), emulator=emulator + config=kwargs.get("config", None), + emulator=emulator, + strict_validation=mimic_qpu, ) create_batch_fn = backoff_decorator(self._sdk_connection.create_batch) batch = create_batch_fn( @@ -192,6 +214,7 @@ def _convert_configuration( self, config: EmulatorConfig | None, emulator: pasqal_cloud.EmulatorType | None, + strict_validation: bool = False, ) -> pasqal_cloud.BaseConfig | None: """Converts a backend configuration into a pasqal_cloud.BaseConfig.""" if emulator is None or config is None: @@ -209,4 +232,5 @@ def _convert_configuration( if emulator == pasqal_cloud.EmulatorType.EMU_TN: pasqal_config_kwargs["dt"] = 1.0 / config.sampling_rate + pasqal_config_kwargs["strict_validation"] = strict_validation return emu_cls(**pasqal_config_kwargs) diff --git a/pulser-simulation/pulser_simulation/qutip_backend.py b/pulser-simulation/pulser_simulation/qutip_backend.py index 7a85f0472..dd57c9862 100644 --- a/pulser-simulation/pulser_simulation/qutip_backend.py +++ b/pulser-simulation/pulser_simulation/qutip_backend.py @@ -31,13 +31,18 @@ class QutipBackend(Backend): Args: sequence: The sequence to emulate. config: The configuration for the Qutip emulator. + mimic_qpu: Whether to mimic the validations necessary for + execution on a QPU. """ def __init__( - self, sequence: Sequence, config: EmulatorConfig = EmulatorConfig() + self, + sequence: Sequence, + config: EmulatorConfig = EmulatorConfig(), + mimic_qpu: bool = False, ): """Initializes a new QutipBackend.""" - super().__init__(sequence) + super().__init__(sequence, mimic_qpu=mimic_qpu) if not isinstance(config, EmulatorConfig): raise TypeError( "'config' must be of type 'EmulatorConfig', " diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 64075c8fb..3608bb766 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -39,6 +39,7 @@ ) from pulser.json.abstract_repr.deserializer import ( VARIABLE_TYPE_MAP, + deserialize_abstract_register, deserialize_device, ) from pulser.json.abstract_repr.serializer import ( @@ -93,6 +94,7 @@ RegisterLayout([[0, 0], [1, 1]]), TriangularLatticeLayout(10, 10), RegisterLayout([[10, 0], [1, 10]], slug="foo"), + RegisterLayout([[0.0, 1.0, 2.0], [-0.4, 1.6, 35.0]]), ], ) def test_layout(layout: RegisterLayout): @@ -107,10 +109,10 @@ def test_layout(layout: RegisterLayout): RegisterLayout.from_abstract_repr(ser_layout_obj) # Check the validation catches invalid entries - with pytest.raises( - jsonschema.exceptions.ValidationError, match="is too long" - ): - ser_layout_obj["coordinates"].append([0, 0, 0]) + with pytest.raises(jsonschema.exceptions.ValidationError): + ser_layout_obj["coordinates"].append( + [0, 0, 0] if layout.dimensionality == 2 else [0, 0] + ) RegisterLayout.from_abstract_repr(json.dumps(ser_layout_obj)) @@ -119,9 +121,11 @@ def test_layout(layout: RegisterLayout): [ Register.from_coordinates(np.array([[0, 0], [1, 1]]), prefix="q"), TriangularLatticeLayout(10, 10).define_register(*[1, 2, 3]), + Register3D(dict(q0=(0, 0, 0), q1=(1, 2, 3))), + RegisterLayout([[0, 0, 0], [1, 1, 1]]).define_register(1), ], ) -def test_register(reg: Register): +def test_register(reg: Register | Register3D): ser_reg_str = reg.to_abstract_repr() ser_reg_obj = json.loads(ser_reg_str) if reg.layout: @@ -131,18 +135,42 @@ def test_register(reg: Register): else: assert "layout" not in ser_reg_obj - re_reg = Register.from_abstract_repr(ser_reg_str) + re_reg = type(reg).from_abstract_repr(ser_reg_str) assert reg == re_reg with pytest.raises(TypeError, match="must be given as a string"): - Register.from_abstract_repr(ser_reg_obj) + type(reg).from_abstract_repr(ser_reg_obj) - # Check the validation catches invalid entries - with pytest.raises( - jsonschema.exceptions.ValidationError, match="'z' was unexpected" - ): - ser_reg_obj["register"].append(dict(name="q10", x=10, y=0, z=1)) - Register.from_abstract_repr(json.dumps(ser_reg_obj)) + with pytest.raises(ValueError, match="must be 2 or 3, not 1"): + deserialize_abstract_register( # type: ignore + ser_reg_str, + expected_dim=1, + ) + + # Without expected_dim, the deserializer returns the right type + re_reg2 = deserialize_abstract_register(ser_reg_str) + assert type(reg) is type(re_reg2) + assert re_reg == re_reg2 + + if reg.dimensionality == 2: + # A 2D register can't be deserialized as a 3D register + with pytest.raises(ValueError, match="must be in 3D, not 2D"): + Register3D.from_abstract_repr(ser_reg_str) + + # Check the validation catches invalid entries + with pytest.raises(jsonschema.exceptions.ValidationError): + ser_reg_obj["register"].append(dict(name="q10", x=10, y=0, z=1)) + Register.from_abstract_repr(json.dumps(ser_reg_obj)) + else: + assert reg.dimensionality == 3 + # A 3D register can't be deserialized as a 2D register + with pytest.raises(ValueError, match="must be in 2D, not 3D"): + Register.from_abstract_repr(ser_reg_str) + + # Check the validation catches invalid entries + with pytest.raises(jsonschema.exceptions.ValidationError): + ser_reg_obj["register"].append(dict(name="q10", x=10, y=0)) + Register.from_abstract_repr(json.dumps(ser_reg_obj)) @pytest.mark.parametrize( @@ -352,6 +380,32 @@ def test_optional_device_fields(self, og_device, field, value): custom_buffer_time=500, ), ), + Rydberg.Global( + None, + None, + mod_bandwidth=5, + eom_config=RydbergEOM( + max_limiting_amp=10, + mod_bandwidth=20, + limiting_beam=RydbergBeam.RED, + intermediate_detuning=1000, + controlled_beams=tuple(RydbergBeam), + red_shift_coeff=1.4, + ), + ), + Rydberg.Global( + None, + None, + mod_bandwidth=5, + eom_config=RydbergEOM( + max_limiting_amp=10, + mod_bandwidth=20, + limiting_beam=RydbergBeam.RED, + intermediate_detuning=1000, + controlled_beams=tuple(RydbergBeam), + blue_shift_coeff=1.4, + ), + ), ], ) def test_optional_channel_fields(self, ch_obj): @@ -407,6 +461,7 @@ def sequence(self, request): max_val = DigitalAnalogDevice.rabi_from_blockade(8) two_pi_wf = BlackmanWaveform.from_max_val(max_val, amps[1]) two_pi_pulse = Pulse.ConstantDetuning(two_pi_wf, 0, 0) + pm_pulse = Pulse.ArbitraryPhase(pi_2_wf, RampWaveform(duration, -1, 1)) seq.align("digital", "rydberg") seq.add(pi_pulse, "rydberg") @@ -415,6 +470,7 @@ def sequence(self, request): seq.add(two_pi_pulse, "rydberg") seq.delay(100, "digital") + seq.add(pm_pulse, "digital") seq.measure("digital") return seq @@ -464,7 +520,7 @@ def test_values(self, abstract): "amps": {"type": "float", "value": [np.pi, 2 * np.pi]}, "duration": {"type": "int", "value": [200]}, } - assert len(abstract["operations"]) == 11 + assert len(abstract["operations"]) == 12 assert abstract["operations"][0] == { "op": "target", "channel": "digital", @@ -552,13 +608,23 @@ def test_values(self, abstract): "time": 100, } + assert abstract["operations"][11] == { + "op": "pulse_arbitrary_phase", + "channel": "digital", + "amplitude": blackman_wf_dict, + "phase": { + "kind": "ramp", + "duration": duration_ref, + "start": -1, + "stop": 1, + }, + "post_phase_shift": 0.0, + "protocol": "min-delay", + } + assert abstract["measurement"] == "digital" def test_exceptions(self, sequence): - with pytest.raises(TypeError, match="not JSON serializable"): - Sequence( - Register3D.cubic(2, prefix="q"), MockDevice - ).to_abstract_repr() with pytest.raises( ValueError, match="No signature found for 'FakeWaveform'" @@ -1113,6 +1179,17 @@ def test_numpy_types(self): == "abc" ) + @pytest.mark.parametrize("skip_validation", [False, True]) + def test_skip_validation(self, sequence, skip_validation): + with patch( + "pulser.json.abstract_repr.serializer.validate_abstract_repr" + ) as mock: + sequence.to_abstract_repr(skip_validation=skip_validation) + if skip_validation: + mock.assert_not_called() + else: + mock.assert_called_once() + def _get_serialized_seq( operations: list[dict] = [], @@ -1245,10 +1322,54 @@ def test_deserialize_register(self, layout_coords): # Check register assert len(seq.register.qubits) == len(s["register"]) + assert seq.register.dimensionality == 2 + assert isinstance(seq.register, Register) + for q in s["register"]: + assert q["name"] in seq.qubit_info + assert seq.qubit_info[q["name"]][0] == q["x"] + assert seq.qubit_info[q["name"]][1] == q["y"] + + # Check layout + if layout_coords is not None: + assert seq.register.layout == reg_layout + q_coords = list(seq.qubit_info.values()) + assert seq.register._layout_info.trap_ids == tuple( + reg_layout.get_traps_from_coordinates(*q_coords) + ) + assert reg_layout.dimensionality == 2 + else: + assert "layout" not in s + assert seq.register.layout is None + + @pytest.mark.parametrize( + "layout_coords", [None, np.array([(0, 0, 0), (1, 2, 3)])] + ) + def test_deserialize_register3D(self, layout_coords): + custom_fields = { + "device": json.loads(MockDevice.to_abstract_repr()), + "register": [ + {"name": "q0", "x": 1.0, "y": 2.0, "z": 3.0}, + ], + } + if layout_coords is not None: + reg_layout = RegisterLayout(layout_coords) + custom_fields["layout"] = { + "coordinates": reg_layout.coords.tolist() + } + + s = _get_serialized_seq(**custom_fields) + _check_roundtrip(s) + seq = Sequence.from_abstract_repr(json.dumps(s)) + + # Check register + assert len(seq.register.qubits) == len(s["register"]) + assert seq.register.dimensionality == 3 + assert isinstance(seq.register, Register3D) for q in s["register"]: assert q["name"] in seq.qubit_info assert seq.qubit_info[q["name"]][0] == q["x"] assert seq.qubit_info[q["name"]][1] == q["y"] + assert seq.qubit_info[q["name"]][2] == q["z"] # Check layout if layout_coords is not None: @@ -1257,6 +1378,7 @@ def test_deserialize_register(self, layout_coords): assert seq.register._layout_info.trap_ids == tuple( reg_layout.get_traps_from_coordinates(*q_coords) ) + assert reg_layout.dimensionality == 3 else: assert "layout" not in s assert seq.register.layout is None @@ -1677,6 +1799,23 @@ def test_deserialize_measurement(self): "stop": 5, }, }, + { + "op": "pulse_arbitrary_phase", + "channel": "global", + "post_phase_shift": var2, + "protocol": "min-delay", + "amplitude": { + "kind": "constant", + "duration": var2, + "value": 3.14, + }, + "phase": { + "kind": "ramp", + "duration": var2, + "start": 1, + "stop": 0, + }, + }, ], ids=_get_op, ) @@ -1723,20 +1862,26 @@ def test_deserialize_parametrized_op(self, op): assert isinstance(c.args[2], VariableItem) # basis is fixed assert c.kwargs["basis"] == "ground-rydberg" - elif op["op"] == "pulse": + elif "pulse" in op["op"]: assert c.name == "add" assert c.kwargs["channel"] == op["channel"] assert c.kwargs["protocol"] == op["protocol"] pulse = c.kwargs["pulse"] assert isinstance(pulse, ParamObj) - assert pulse.cls == Pulse - assert isinstance(pulse.kwargs["phase"], VariableItem) + if op["op"] == "pulse": + assert pulse.cls is Pulse + assert isinstance(pulse.kwargs["phase"], VariableItem) + time_domain_mod = "detuning" + else: + assert pulse.args[0] is Pulse + assert op["op"] == "pulse_arbitrary_phase" + time_domain_mod = "phase" assert isinstance(pulse.kwargs["post_phase_shift"], VariableItem) assert isinstance(pulse.kwargs["amplitude"], ParamObj) assert issubclass(pulse.kwargs["amplitude"].cls, Waveform) - assert isinstance(pulse.kwargs["detuning"], ParamObj) - assert issubclass(pulse.kwargs["detuning"].cls, Waveform) + assert isinstance(pulse.kwargs[time_domain_mod], ParamObj) + assert issubclass(pulse.kwargs[time_domain_mod].cls, Waveform) else: assert False, f"operation type \"{op['op']}\" is not valid" @@ -1805,6 +1950,25 @@ def test_deserialize_parametrized_op(self, op): }, "ConstantDetuning", ), + ( + { + "op": "pulse_arbitrary_phase", + "channel": "global", + "post_phase_shift": var2, + "protocol": "min-delay", + "amplitude": { + "kind": "constant", + "duration": var2, + "value": 3.14, + }, + "phase": { + "kind": "constant", + "duration": var2, + "value": 1, + }, + }, + "ArbitraryPhase", + ), ], ) @pytest.mark.filterwarnings( @@ -1835,7 +1999,6 @@ def test_deserialize_parametrized_pulse(self, op, pulse_cls): pulse = c.kwargs["pulse"] assert isinstance(pulse, ParamObj) assert pulse.cls.__name__ == pulse_cls - assert isinstance(pulse.kwargs["phase"], VariableItem) assert isinstance(pulse.kwargs["post_phase_shift"], VariableItem) if pulse_cls != "ConstantAmplitude": @@ -1844,11 +2007,19 @@ def test_deserialize_parametrized_pulse(self, op, pulse_cls): else: assert pulse.kwargs["amplitude"] == 3.14 - if pulse_cls != "ConstantDetuning": + if pulse_cls == "ConstantAmplitude": assert isinstance(pulse.kwargs["detuning"], ParamObj) assert issubclass(pulse.kwargs["detuning"].cls, Waveform) - else: + elif pulse_cls == "ConstantDetuning": assert pulse.kwargs["detuning"] == 1 + elif pulse_cls == "ArbitraryPhase": + assert "detuning" not in pulse.kwargs + + if pulse_cls != "ArbitraryPhase": + assert isinstance(pulse.kwargs["phase"], VariableItem) + else: + assert isinstance(pulse.kwargs["phase"], ParamObj) + assert issubclass(pulse.kwargs["phase"].cls, Waveform) @pytest.mark.parametrize("correct_phase_drift", (False, True, None)) @pytest.mark.parametrize("var_detuning_on", [False, True]) diff --git a/tests/test_backend.py b/tests/test_backend.py index 4da4acc1b..4ebacb16c 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -15,9 +15,7 @@ import re import typing -from dataclasses import replace -import numpy as np import pytest import pulser @@ -30,8 +28,8 @@ RemoteResultsError, SubmissionStatus, ) -from pulser.devices import DigitalAnalogDevice, MockDevice -from pulser.noise_model import NoiseModel +from pulser.devices import AnalogDevice, MockDevice +from pulser.register import SquareLatticeLayout from pulser.result import Result, SampledResult @@ -88,140 +86,11 @@ def test_emulator_config_type_errors(param, msg): EmulatorConfig(**{param: None}) -class TestNoiseModel: - def test_bad_noise_type(self): - with pytest.raises( - ValueError, match="'bad_noise' is not a valid noise type." - ): - NoiseModel(noise_types=("bad_noise",)) - - @pytest.mark.parametrize( - "param", - ["runs", "samples_per_run", "temperature", "laser_waist"], - ) - def test_init_strict_pos(self, param): - with pytest.raises( - ValueError, match=f"'{param}' must be greater than zero, not 0" - ): - NoiseModel(**{param: 0}) - - @pytest.mark.parametrize("value", [-1e-9, 0.2, 1.0001]) - @pytest.mark.parametrize( - "param", - [ - "dephasing_rate", - "hyperfine_dephasing_rate", - "relaxation_rate", - "depolarizing_rate", - ], - ) - def test_init_rate_like(self, param, value): - if value < 0: - with pytest.raises( - ValueError, - match=f"'{param}' must be None or greater " - f"than or equal to zero, not {value}.", - ): - NoiseModel(**{param: value}) - else: - noise_model = NoiseModel(**{param: value}) - assert getattr(noise_model, param) == value - - @pytest.mark.parametrize("value", [-1e-9, 1.0001]) - @pytest.mark.parametrize( - "param", - [ - "state_prep_error", - "p_false_pos", - "p_false_neg", - "amp_sigma", - ], - ) - def test_init_prob_like(self, param, value): - with pytest.raises( - ValueError, - match=f"'{param}' must be greater than or equal to zero and " - f"smaller than or equal to one, not {value}", - ): - NoiseModel(**{param: value}) - - @pytest.fixture - def matrices(self): - matrices = {} - matrices["I"] = np.eye(2) - matrices["X"] = np.ones((2, 2)) - np.eye(2) - matrices["Y"] = np.array([[0, -1j], [1j, 0]]) - matrices["Zh"] = 0.5 * np.array([[1, 0], [0, -1]]) - matrices["ket"] = np.array([[1.0], [2.0]]) - matrices["I3"] = np.eye(3) - return matrices - - def test_eff_noise_rates(self, matrices): - with pytest.raises( - ValueError, match="The provided rates must be greater than 0." - ): - NoiseModel( - noise_types=("eff_noise",), - eff_noise_opers=[matrices["I"], matrices["X"]], - eff_noise_rates=[-1.0, 0.5], - ) - - def test_eff_noise_opers(self, matrices): - with pytest.raises(ValueError, match="The operators list length"): - NoiseModel(noise_types=("eff_noise",), eff_noise_rates=[1.0]) - with pytest.raises( - TypeError, match="eff_noise_rates is a list of floats" - ): - NoiseModel( - noise_types=("eff_noise",), - eff_noise_rates=["0.1"], - eff_noise_opers=[np.eye(2)], - ) - with pytest.raises( - ValueError, - match="The effective noise parameters have not been filled.", - ): - NoiseModel(noise_types=("eff_noise",)) - with pytest.raises(TypeError, match="not castable to a Numpy array"): - NoiseModel( - noise_types=("eff_noise",), - eff_noise_rates=[2.0], - eff_noise_opers=[{(1.0, 0), (0.0, -1)}], - ) - with pytest.raises(ValueError, match="is not a 2D array."): - NoiseModel( - noise_types=("eff_noise",), - eff_noise_opers=[2.0], - eff_noise_rates=[1.0], - ) - with pytest.raises(NotImplementedError, match="Operator's shape"): - NoiseModel( - noise_types=("eff_noise",), - eff_noise_opers=[matrices["I3"]], - eff_noise_rates=[1.0], - ) - - def test_eq(self, matrices): - final_fields = dict( - noise_types=("SPAM", "eff_noise"), - eff_noise_rates=(0.1, 0.4), - eff_noise_opers=(((0, 1), (1, 0)), ((0, -1j), (1j, 0))), - ) - noise_model = NoiseModel( - noise_types=["SPAM", "eff_noise"], - eff_noise_rates=[0.1, 0.4], - eff_noise_opers=[matrices["X"], matrices["Y"]], - ) - assert noise_model == NoiseModel(**final_fields) - for param in final_fields: - assert final_fields[param] == getattr(noise_model, param) - - class _MockConnection(RemoteConnection): def __init__(self): self._status_calls = 0 - def submit(self, sequence, wait: bool = False, **kwargsn) -> RemoteResults: + def submit(self, sequence, wait: bool = False, **kwargs) -> RemoteResults: return RemoteResults("abcd", self) def _fetch_result(self, submission_id: str) -> typing.Sequence[Result]: @@ -247,11 +116,30 @@ def test_qpu_backend(sequence): TypeError, match="must be a real device, instance of 'Device'" ): QPUBackend(sequence, connection) - with pytest.warns(DeprecationWarning, match="From v0.18"): - seq = sequence.switch_device(replace(DigitalAnalogDevice, max_runs=10)) + + with pytest.warns( + UserWarning, match="device with a different Rydberg level" + ): + seq = sequence.switch_device(AnalogDevice) + with pytest.raises(ValueError, match="defined from a `RegisterLayout`"): + QPUBackend(seq, connection) + seq = seq.switch_register(SquareLatticeLayout(5, 5, 5).square_register(2)) + with pytest.raises( + ValueError, match="does not accept new register layouts" + ): + QPUBackend(seq, connection) + seq = seq.switch_register( + AnalogDevice.pre_calibrated_layouts[0].define_register(1, 2, 3) + ) qpu_backend = QPUBackend(seq, connection) with pytest.raises(ValueError, match="'job_params' must be specified"): qpu_backend.run() + with pytest.raises(TypeError, match="'job_params' must be a list"): + qpu_backend.run(job_params={"runs": 100}) + with pytest.raises( + TypeError, match="All elements of 'job_params' must be dictionaries" + ): + qpu_backend.run(job_params=[{"runs": 100}, "foo"]) with pytest.raises( ValueError, match="All elements of 'job_params' must specify 'runs'", @@ -261,10 +149,10 @@ def test_qpu_backend(sequence): with pytest.raises( ValueError, match=re.escape( - "All 'runs' must be below the maximum allowed by the device (10)" + "All 'runs' must be below the maximum allowed by the device" ), ): - qpu_backend.run(job_params=[{"runs": 11}]) + qpu_backend.run(job_params=[{"runs": 100000}]) remote_results = qpu_backend.run(job_params=[{"runs": 10}]) diff --git a/tests/test_eom.py b/tests/test_eom.py index 7f428b05c..58f61833f 100644 --- a/tests/test_eom.py +++ b/tests/test_eom.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np import pytest from pulser.channels.eom import MODBW_TO_TR, RydbergBeam, RydbergEOM @@ -39,6 +40,10 @@ def params(): ("intermediate_detuning", 0), ("custom_buffer_time", 0.1), ("custom_buffer_time", 0), + ("blue_shift_coeff", -1e-3), + ("blue_shift_coeff", 0), + ("red_shift_coeff", -1.1), + ("red_shift_coeff", 0), ], ) def test_bad_value_init_eom(bad_param, bad_value, params): @@ -93,13 +98,32 @@ def test_bad_controlled_beam(params): assert RydbergEOM(**params).controlled_beams == tuple(RydbergBeam) +@pytest.mark.parametrize("limiting_beam", list(RydbergBeam)) +@pytest.mark.parametrize("blue_shift_coeff", [0.5, 1.0, 2.0]) +@pytest.mark.parametrize("red_shift_coeff", [0.5, 1.0, 1.8]) @pytest.mark.parametrize("multiple_beam_control", [True, False]) @pytest.mark.parametrize("limit_amp_fraction", [0.5, 2]) -def test_detuning_off(multiple_beam_control, limit_amp_fraction, params): +def test_detuning_off( + limiting_beam, + blue_shift_coeff, + red_shift_coeff, + multiple_beam_control, + limit_amp_fraction, + params, +): params["multiple_beam_control"] = multiple_beam_control + params["blue_shift_coeff"] = blue_shift_coeff + params["red_shift_coeff"] = red_shift_coeff + params["limiting_beam"] = limiting_beam eom = RydbergEOM(**params) - limit_amp = params["max_limiting_amp"] ** 2 / ( - 2 * params["intermediate_detuning"] + limit_amp = ( + params["max_limiting_amp"] ** 2 + / (2 * params["intermediate_detuning"]) + * np.sqrt( + red_shift_coeff / blue_shift_coeff + if limiting_beam == RydbergBeam.RED + else blue_shift_coeff / red_shift_coeff + ) ) amp = limit_amp_fraction * limit_amp @@ -107,30 +131,55 @@ 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 + # Below limit_amp, 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"] + limit_amp_ = params["max_limiting_amp"] + non_limit_amp = 2 * params["intermediate_detuning"] * amp / limit_amp_ + red_amp = ( + limit_amp_ if limiting_beam == RydbergBeam.RED else non_limit_amp ) + blue_amp = ( + limit_amp_ if limiting_beam == RydbergBeam.BLUE else non_limit_amp + ) + # The offset to have resonance when the pulse is on is -lightshift + return -( + blue_shift_coeff * blue_amp**2 - red_shift_coeff * 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 np.isclose(eom._lightshift(amp, *RydbergBeam), -zero_det) assert eom._lightshift(amp) == 0.0 det_off_options = eom.detuning_off_options(amp, detuning_on) + switching_beams_opts = eom._switching_beams_combos + assert len(det_off_options) == len(switching_beams_opts) assert len(det_off_options) == 2 + multiple_beam_control - det_off_options.sort() + order = np.argsort(det_off_options) + det_off_options = det_off_options[order] + switching_beams_opts = [switching_beams_opts[ind] for ind in order] assert det_off_options[0] < zero_det # RED on + assert switching_beams_opts[0] == (RydbergBeam.BLUE,) next_ = 1 if multiple_beam_control: - assert det_off_options[next_] == zero_det # All off + assert np.isclose(det_off_options[next_], zero_det) # All off + assert switching_beams_opts[1] == tuple(RydbergBeam) next_ += 1 assert det_off_options[next_] > zero_det # BLUE on + assert switching_beams_opts[next_] == (RydbergBeam.RED,) + calculated_det_off, switching_beams = eom.calculate_detuning_off( + amp, + detuning_on, + optimal_detuning_off=0, + return_switching_beams=True, + ) + assert ( + switching_beams + == switching_beams_opts[ + det_off_options.tolist().index(calculated_det_off) + ] + ) + assert calculated_det_off == min(det_off_options, key=abs) # Case where the EOM pulses are off-resonant detuning_on = 1.0 @@ -143,4 +192,7 @@ def calc_offset(amp): 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 + assert np.isclose(off_options[0], det_off_options[ind] + detuning_on) + assert off_options[0] == eom_.calculate_detuning_off( + amp, detuning_on, optimal_detuning_off=0.0 + ) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py new file mode 100644 index 000000000..c466caef5 --- /dev/null +++ b/tests/test_noise_model.py @@ -0,0 +1,146 @@ +# Copyright 2024 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 numpy as np +import pytest + +from pulser.noise_model import NoiseModel + + +class TestNoiseModel: + def test_bad_noise_type(self): + with pytest.raises( + ValueError, match="'bad_noise' is not a valid noise type." + ): + NoiseModel(noise_types=("bad_noise",)) + + @pytest.mark.parametrize( + "param", + ["runs", "samples_per_run", "temperature", "laser_waist"], + ) + def test_init_strict_pos(self, param): + with pytest.raises( + ValueError, match=f"'{param}' must be greater than zero, not 0" + ): + NoiseModel(**{param: 0}) + + @pytest.mark.parametrize("value", [-1e-9, 0.2, 1.0001]) + @pytest.mark.parametrize( + "param", + [ + "dephasing_rate", + "hyperfine_dephasing_rate", + "relaxation_rate", + "depolarizing_rate", + ], + ) + def test_init_rate_like(self, param, value): + if value < 0: + with pytest.raises( + ValueError, + match=f"'{param}' must be None or greater " + f"than or equal to zero, not {value}.", + ): + NoiseModel(**{param: value}) + else: + noise_model = NoiseModel(**{param: value}) + assert getattr(noise_model, param) == value + + @pytest.mark.parametrize("value", [-1e-9, 1.0001]) + @pytest.mark.parametrize( + "param", + [ + "state_prep_error", + "p_false_pos", + "p_false_neg", + "amp_sigma", + ], + ) + def test_init_prob_like(self, param, value): + with pytest.raises( + ValueError, + match=f"'{param}' must be greater than or equal to zero and " + f"smaller than or equal to one, not {value}", + ): + NoiseModel(**{param: value}) + + @pytest.fixture + def matrices(self): + matrices = {} + matrices["I"] = np.eye(2) + matrices["X"] = np.ones((2, 2)) - np.eye(2) + matrices["Y"] = np.array([[0, -1j], [1j, 0]]) + matrices["Zh"] = 0.5 * np.array([[1, 0], [0, -1]]) + matrices["ket"] = np.array([[1.0], [2.0]]) + matrices["I3"] = np.eye(3) + return matrices + + def test_eff_noise_rates(self, matrices): + with pytest.raises( + ValueError, match="The provided rates must be greater than 0." + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["I"], matrices["X"]], + eff_noise_rates=[-1.0, 0.5], + ) + + def test_eff_noise_opers(self, matrices): + with pytest.raises(ValueError, match="The operators list length"): + NoiseModel(noise_types=("eff_noise",), eff_noise_rates=[1.0]) + with pytest.raises( + TypeError, match="eff_noise_rates is a list of floats" + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_rates=["0.1"], + eff_noise_opers=[np.eye(2)], + ) + with pytest.raises( + ValueError, + match="The effective noise parameters have not been filled.", + ): + NoiseModel(noise_types=("eff_noise",)) + with pytest.raises(TypeError, match="not castable to a Numpy array"): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_rates=[2.0], + eff_noise_opers=[{(1.0, 0), (0.0, -1)}], + ) + with pytest.raises(ValueError, match="is not a 2D array."): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[2.0], + eff_noise_rates=[1.0], + ) + with pytest.raises(NotImplementedError, match="Operator's shape"): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["I3"]], + eff_noise_rates=[1.0], + ) + + def test_eq(self, matrices): + final_fields = dict( + noise_types=("SPAM", "eff_noise"), + eff_noise_rates=(0.1, 0.4), + eff_noise_opers=(((0, 1), (1, 0)), ((0, -1j), (1j, 0))), + ) + noise_model = NoiseModel( + noise_types=["SPAM", "eff_noise"], + eff_noise_rates=[0.1, 0.4], + eff_noise_opers=[matrices["X"], matrices["Y"]], + ) + assert noise_model == NoiseModel(**final_fields) + for param in final_fields: + assert final_fields[param] == getattr(noise_model, param) diff --git a/tests/test_pasqal.py b/tests/test_pasqal.py index 8b7b98c95..0fc950e07 100644 --- a/tests/test_pasqal.py +++ b/tests/test_pasqal.py @@ -59,7 +59,9 @@ class CloudFixture: ), ), ) -virtual_device = test_device.to_virtual() +virtual_device = dataclasses.replace( + test_device.to_virtual(), name="test-virtual" +) @pytest.fixture @@ -130,11 +132,12 @@ def fixt(mock_batch): mock_cloud_sdk_class.assert_not_called() +@pytest.mark.parametrize("mimic_qpu", [False, True]) @pytest.mark.parametrize( "emulator", [None, EmulatorType.EMU_TN, EmulatorType.EMU_FREE] ) @pytest.mark.parametrize("parametrized", [True, False]) -def test_submit(fixt, parametrized, emulator, seq, mock_job): +def test_submit(fixt, parametrized, emulator, mimic_qpu, seq, mock_job): with pytest.raises( ValueError, match="The measurement basis can't be implicitly determined for a " @@ -147,7 +150,7 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): seq.delay(t if parametrized else 100, "rydberg_global") assert seq.is_parametrized() == parametrized - if not emulator: + if not emulator or mimic_qpu: seq2 = seq.switch_device(virtual_device) with pytest.raises( ValueError, @@ -155,11 +158,36 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): "of the devices currently available through the remote " "connection.", ): - fixt.pasqal_cloud.submit(seq2, job_params=[dict(runs=10)]) + fixt.pasqal_cloud.submit( + seq2, job_params=[dict(runs=10)], mimic_qpu=mimic_qpu + ) + mod_test_device = dataclasses.replace(test_device, max_atom_num=1000) + seq3 = seq.switch_device(mod_test_device).switch_register( + pulser.Register.square(11, spacing=5) + ) + with pytest.raises( + ValueError, + match="sequence is not compatible with the latest device specs", + ): + fixt.pasqal_cloud.submit( + seq3, job_params=[dict(runs=10)], mimic_qpu=mimic_qpu + ) + seq4 = seq3.switch_register(pulser.Register.square(4, spacing=5)) + # The sequence goes through QPUBackend.validate_sequence() + with pytest.raises( + ValueError, match="defined from a `RegisterLayout`" + ): + fixt.pasqal_cloud.submit( + seq4, job_params=[dict(runs=10)], mimic_qpu=mimic_qpu + ) + + # And it goes through QPUBackend.validate_job_params() + with pytest.raises( + ValueError, + match="must specify 'runs'", + ): + fixt.pasqal_cloud.submit(seq, job_params=[{}], mimic_qpu=mimic_qpu) - assert fixt.pasqal_cloud.fetch_available_devices() == { - test_device.name: test_device - } if parametrized: with pytest.raises( TypeError, match="Did not receive values for variables" @@ -167,6 +195,7 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): fixt.pasqal_cloud.submit( seq.build(qubits={"q0": 1, "q1": 2, "q2": 4, "q3": 3}), job_params=[{"runs": 10}], + mimic_qpu=mimic_qpu, ) assert not seq.is_measured() @@ -177,12 +206,20 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): if emulator is None: sdk_config = None elif emulator == EmulatorType.EMU_FREE: - sdk_config = EmuFreeConfig(with_noise=False) + sdk_config = EmuFreeConfig( + with_noise=False, strict_validation=mimic_qpu + ) else: - sdk_config = EmuTNConfig(dt=2, extra_config={"with_noise": False}) + sdk_config = EmuTNConfig( + dt=2, + extra_config={"with_noise": False}, + strict_validation=mimic_qpu, + ) assert ( - fixt.pasqal_cloud._convert_configuration(config, emulator) + fixt.pasqal_cloud._convert_configuration( + config, emulator, strict_validation=mimic_qpu + ) == sdk_config ) @@ -201,6 +238,7 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): job_params=job_params, emulator=emulator, config=config, + mimic_qpu=mimic_qpu, ) assert not seq.is_measured() seq.measure(basis="ground-rydberg") @@ -283,21 +321,37 @@ def test_emulators_init(fixt, seq, emu_cls, monkeypatch): ): emu_cls(seq, RemoteConnection()) + # With mimic_qpu=True + with pytest.raises(TypeError, match="must be a real device"): + emu_cls( + seq.switch_device(virtual_device), + fixt.pasqal_cloud, + mimic_qpu=True, + ) + +@pytest.mark.parametrize("mimic_qpu", [True, False]) @pytest.mark.parametrize("parametrized", [True, False]) @pytest.mark.parametrize("emu_cls", [EmuTNBackend, EmuFreeBackend]) -def test_emulators_run(fixt, seq, emu_cls, parametrized: bool): +def test_emulators_run(fixt, seq, emu_cls, parametrized: bool, mimic_qpu): seq.declare_channel("rydberg_global", "rydberg_global") t = seq.declare_variable("t", dtype=int) seq.delay(t if parametrized else 100, "rydberg_global") assert seq.is_parametrized() == parametrized seq.measure(basis="ground-rydberg") - emu = emu_cls(seq, fixt.pasqal_cloud) + emu = emu_cls(seq, fixt.pasqal_cloud, mimic_qpu=mimic_qpu) with pytest.raises(ValueError, match="'job_params' must be specified"): emu.run() + with pytest.raises(TypeError, match="'job_params' must be a list"): + emu.run(job_params={"runs": 100}) + with pytest.raises( + TypeError, match="All elements of 'job_params' must be dictionaries" + ): + emu.run(job_params=[{"runs": 100}, "foo"]) + with pytest.raises(ValueError, match="must specify 'runs'"): emu.run(job_params=[{}]) @@ -318,10 +372,10 @@ def test_emulators_run(fixt, seq, emu_cls, parametrized: bool): sdk_config: EmuTNConfig | EmuFreeConfig if isinstance(emu, EmuTNBackend): emulator_type = EmulatorType.EMU_TN - sdk_config = EmuTNConfig(dt=10) + sdk_config = EmuTNConfig(dt=10, strict_validation=mimic_qpu) else: emulator_type = EmulatorType.EMU_FREE - sdk_config = EmuFreeConfig() + sdk_config = EmuFreeConfig(strict_validation=mimic_qpu) fixt.mock_cloud_sdk.create_batch.assert_called_once() fixt.mock_cloud_sdk.create_batch.assert_called_once_with( serialized_sequence=seq.to_abstract_repr(), diff --git a/tests/test_pulse.py b/tests/test_pulse.py index d9c1ee5f1..8c575a2b1 100644 --- a/tests/test_pulse.py +++ b/tests/test_pulse.py @@ -16,10 +16,17 @@ import numpy as np 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 +from pulser.parametrized import ParamObj, Variable +from pulser.pulse import PHASE_PRECISION, Pulse +from pulser.waveforms import ( + BlackmanWaveform, + ConstantWaveform, + CustomWaveform, + InterpolatedWaveform, + RampWaveform, +) cwf = ConstantWaveform(100, -10) bwf = BlackmanWaveform(200, 3) @@ -59,15 +66,18 @@ def test_str(): "Pulse(Amp=1 rad/µs, Detuning=-10 rad/µs, " + "Phase=3.14)" ) pls_ = Pulse(bwf, rwf, 1) - msg = "Pulse(Amp=Blackman(Area: 3), Detuning=Ramp(0->1 rad/µs), Phase=1)" + msg = ( + "Pulse(Amp=Blackman(Area: 3) rad/µs, Detuning=Ramp(0->1) rad/µs, " + "Phase=1)" + ) assert pls_.__str__() == msg def test_repr(): pls_ = Pulse(bwf, rwf, 1, post_phase_shift=-np.pi) msg = ( - "Pulse(amp=BlackmanWaveform(200 ns, Area: 3), " - + "detuning=RampWaveform(200 ns, 0->1 rad/µs), " + "Pulse(amp=BlackmanWaveform(200 ns, Area: 3) rad/µs, " + + "detuning=RampWaveform(200 ns, 0->1) rad/µs, " + "phase=1, post_phase_shift=3.14)" ) assert pls_.__repr__() == msg @@ -121,6 +131,92 @@ def test_full_duration(eom_channel): ) == pls.duration + pls.fall_time(eom_channel, in_eom_mode=True) +@pytest.mark.parametrize( + "phase_wf, det_wf, phase_0", + [ + ( + ConstantWaveform(200, -123), + ConstantWaveform(200, 0), + -123 % (2 * np.pi), + ), + ( + RampWaveform(200, -5, 5), + ConstantWaveform(200, (_slope := -10 / 199) * 1e3), + (-5 + _slope) % (2 * np.pi), + ), + ( + -bwf, + CustomWaveform( + np.pad(np.diff(bwf.samples), (1, 0), mode="edge") * 1e3 + ), + -bwf[0] + (-bwf[0] + bwf[1]), + ), + ( + interp_wf := InterpolatedWaveform(200, values=[1, 3, -2, 4]), + CustomWaveform( + np.pad(-np.diff(interp_wf.samples), (1, 0), mode="edge") * 1e3 + ), + interp_wf[0] + (interp_wf[0] - interp_wf[1]), + ), + ], +) +def test_arbitrary_phase(phase_wf, det_wf, phase_0): + with pytest.raises(TypeError, match="must be a waveform"): + Pulse.ArbitraryPhase(bwf, -3) + + pls_ = Pulse.ArbitraryPhase(bwf, phase_wf) + assert pls_ == Pulse(bwf, det_wf, phase_0) + + calculated_phase = -np.cumsum(pls_.detuning.samples * 1e-3) + phase_0 + assert np.allclose( + calculated_phase % (2 * np.pi), + phase_wf.samples % (2 * np.pi), + atol=PHASE_PRECISION, + # The shift makes sure we don't fail around the wrapping point + ) or np.allclose( + (calculated_phase + 1) % (2 * np.pi), + (phase_wf.samples + 1) % (2 * np.pi), + atol=PHASE_PRECISION, + ) + + +def test_parametrized_pulses(): + vars = Variable("vars", float, size=2) + vars._assign([1000, 1.0]) + param_bwf = BlackmanWaveform(vars[0], vars[1]) + const_pulse = Pulse.ConstantPulse(vars[0], vars[1], vars[1], vars[1]) + assert isinstance(const_pulse, ParamObj) + assert const_pulse.cls is Pulse + param_const = ConstantWaveform(vars[0], vars[1]) + assert ( + const_pulse.build() == Pulse(param_const, param_const, vars[1]).build() + ) + const_amp = Pulse.ConstantAmplitude(vars[1], param_bwf, vars[1]) + assert const_amp.cls.__name__ == "ConstantAmplitude" + const_det = Pulse.ConstantDetuning(param_bwf, vars[1], vars[1]) + assert const_det.cls.__name__ == "ConstantDetuning" + arb_phase = Pulse.ArbitraryPhase( + param_bwf, RampWaveform(vars[0], 0, vars[1]) + ) + assert arb_phase.cls.__name__ == "ArbitraryPhase" + special_pulses = [const_amp, const_det, arb_phase] + for p in special_pulses: + assert isinstance(p, ParamObj) + assert p.cls is not Pulse + assert p.args[0] is Pulse + + assert const_amp.build() == Pulse(param_const, param_bwf, vars[1]).build() + assert const_det.build() == Pulse(param_bwf, param_const, vars[1]).build() + assert ( + arb_phase.build() + == Pulse( + param_bwf, + ConstantWaveform(vars[0], -vars[1] * 1e3 / (vars[0] - 1)), + -vars[1] / (vars[0] - 1), + ).build() + ) + + def test_eq(): assert (pls_ := Pulse.ConstantPulse(100, 1, -1, 0)) == Pulse( ConstantWaveform(100, 1), diff --git a/tests/test_qutip_backend.py b/tests/test_qutip_backend.py index 63bbc95c5..0214f00dc 100644 --- a/tests/test_qutip_backend.py +++ b/tests/test_qutip_backend.py @@ -21,6 +21,7 @@ import pulser from pulser.devices import MockDevice +from pulser.register import SquareLatticeLayout from pulser.waveforms import BlackmanWaveform from pulser_simulation import SimConfig from pulser_simulation.qutip_backend import QutipBackend @@ -56,6 +57,17 @@ def test_qutip_backend(sequence): assert final_state == results.get_final_state() np.testing.assert_allclose(final_state.full(), [[0], [1]], atol=1e-5) + # Test mimic QPU + with pytest.raises(TypeError, match="must be a real device"): + QutipBackend(sequence, mimic_qpu=True) + sequence = sequence.switch_device(pulser.DigitalAnalogDevice) + with pytest.raises(ValueError, match="defined from a `RegisterLayout`"): + QutipBackend(sequence, mimic_qpu=True) + sequence = sequence.switch_register( + SquareLatticeLayout(5, 5, 5).square_register(2) + ) + QutipBackend(sequence, mimic_qpu=True) + def test_with_default_noise(sequence): spam_noise = pulser.NoiseModel(noise_types=("SPAM",)) diff --git a/tests/test_sequence.py b/tests/test_sequence.py index 6d7ecd237..4b4115b41 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import contextlib import dataclasses import itertools import json @@ -483,6 +484,7 @@ def init_seq( parametrized=False, mappable_reg=False, config_det_map=False, + prefer_slm_mask=True, ) -> Sequence: register = ( reg.layout.make_mappable_register(len(reg.qubits)) @@ -506,7 +508,7 @@ def init_seq( for i in range(10) } ) - if mappable_reg: + if mappable_reg or not prefer_slm_mask: seq.config_detuning_map(detuning_map=det_map, dmm_id="dmm_0") else: seq.config_slm_mask(["q0"], "dmm_0") @@ -533,6 +535,100 @@ def test_ising_mode( seq2._in_ising = True +@pytest.mark.parametrize("config_det_map", [False, True]) +@pytest.mark.parametrize("starts_mappable", [False, True]) +@pytest.mark.parametrize("mappable_reg", [False, True]) +@pytest.mark.parametrize("parametrized", [False, True]) +def test_switch_register( + reg, mappable_reg, parametrized, starts_mappable, config_det_map +): + pulse = Pulse.ConstantPulse(1000, 1, -1, 2) + with_slm_mask = not starts_mappable and not mappable_reg + seq = init_seq( + reg, + DigitalAnalogDevice, + "raman", + "raman_local", + [pulse], + initial_target="q0", + parametrized=parametrized, + mappable_reg=starts_mappable, + config_det_map=config_det_map, + prefer_slm_mask=with_slm_mask, + ) + + with pytest.raises( + ValueError, + match="given ids have to be qubit ids declared in this sequence's" + " register", + ): + seq.switch_register(Register(dict(q1=(0, 0), qN=(10, 10)))) + + seq.declare_channel("ryd", "rydberg_global") + seq.add(pulse, "ryd", protocol="no-delay") + + if mappable_reg: + new_reg = TriangularLatticeLayout(10, 5).make_mappable_register(2) + else: + new_reg = Register(dict(q0=(0, 0), foo=(10, 10))) + + if config_det_map and not with_slm_mask: + context_manager = pytest.warns( + UserWarning, match="configures a detuning map" + ) + else: + context_manager = contextlib.nullcontext() + + with context_manager: + new_seq = seq.switch_register(new_reg) + assert seq.declared_variables or not parametrized + assert seq.declared_variables == new_seq.declared_variables + assert new_seq.is_parametrized() == parametrized + assert new_seq.is_register_mappable() == mappable_reg + assert new_seq._calls[1:] == seq._calls[1:] # Excludes __init__ + assert new_seq._to_build_calls == seq._to_build_calls + + build_kwargs = {} + if parametrized: + build_kwargs["delay"] = 120 + if mappable_reg: + build_kwargs["qubits"] = {"q0": 1, "q1": 4} + if build_kwargs: + new_seq = new_seq.build(**build_kwargs) + + assert isinstance( + (raman_pulse_slot := new_seq._schedule["raman"][1]).type, Pulse + ) + assert raman_pulse_slot.type == pulse + assert raman_pulse_slot.targets == {"q0"} + + assert isinstance( + (rydberg_pulse_slot := new_seq._schedule["ryd"][1]).type, Pulse + ) + assert rydberg_pulse_slot.type == pulse + assert rydberg_pulse_slot.targets == set(new_reg.qubit_ids) + + if config_det_map: + if with_slm_mask: + if parametrized: + seq = seq.build(**build_kwargs) + assert np.any(reg.qubits["q0"] != new_reg.qubits["q0"]) + assert "dmm_0" in seq.declared_channels + prev_qubit_wmap = seq._schedule[ + "dmm_0" + ].detuning_map.get_qubit_weight_map(reg.qubits) + new_qubit_wmap = new_seq._schedule[ + "dmm_0" + ].detuning_map.get_qubit_weight_map(new_reg.qubits) + assert prev_qubit_wmap["q0"] == 1.0 + assert new_qubit_wmap == dict(q0=1.0, foo=0.0) + elif not parametrized: + assert ( + seq._schedule["dmm_0"].detuning_map + == new_seq._schedule["dmm_0"].detuning_map + ) + + @pytest.mark.parametrize("mappable_reg", [False, True]) @pytest.mark.parametrize("parametrized", [False, True]) def test_switch_device_down( @@ -1102,7 +1198,7 @@ def test_delay_min_duration(reg, device): ) -def test_phase(reg, device): +def test_phase(reg, device, det_map): seq = Sequence(reg, device) seq.declare_channel("ch0", "raman_local", initial_target="q0") seq.phase_shift(-1, "q0", "q1") @@ -1131,6 +1227,35 @@ def test_phase(reg, device): assert seq.current_phase_ref("q1", "digital") == 0 assert seq.current_phase_ref("q10", "digital") == 1 + # Check that the phase of DMM pulses is unaffected + seq.add(Pulse.ConstantPulse(100, 1, 0, 0), "ch1") + seq.config_detuning_map(det_map, "dmm_0") + det_wf = RampWaveform(100, -10, -1) + seq.add_dmm_detuning(det_wf, "dmm_0") + # We shift the phase of just one qubit, which blocks addition + # of new pulses on this basis + seq.phase_shift(1.0, "q0", basis="ground-rydberg") + with pytest.raises( + ValueError, + match="Cannot do a multiple-target pulse on qubits with different " + "phase references for the same basis.", + ): + seq.add(Pulse.ConstantPulse(100, 1, 0, 0), "ch1") + # But it works on the DMM + seq.add_dmm_detuning(det_wf, "dmm_0") + + seq_samples = sample(seq) + # The phase on the rydberg channel matches the phase ref + np.testing.assert_array_equal( + seq_samples.channel_samples["ch1"].phase, + seq.current_phase_ref("q1", basis="ground-rydberg"), + ) + + # but the phase in the DMMSamples stays at zero + np.testing.assert_array_equal( + sample(seq).channel_samples["dmm_0"].phase, 0.0 + ) + def test_align(reg, device): seq = Sequence(reg, device) @@ -1247,7 +1372,7 @@ def test_str(reg, device, mod_device, det_map): f"\n\nChannel: dmm_0\nt: 0 | Initial targets: {targets} " "| Phase Reference: 0.0 " f"\nt: 0->100 | Detuning: -10 rad/µs | Targets: {targets}" - f"\nt: 100->200 | Detuning: Ramp(-10->0 rad/µs) | Targets: {targets}" + f"\nt: 100->200 | Detuning: Ramp(-10->0) rad/µs | Targets: {targets}" ) measure_msg = "\n\nMeasured in basis: digital" @@ -1360,6 +1485,7 @@ def test_sequence(reg, device, patch_plt_show): seq.draw(draw_phase_area=True) seq.draw(draw_phase_curve=True) + seq.draw(as_phase_modulated=True) s = seq._serialize() assert json.loads(s)["__version__"] == pulser.__version__ @@ -1548,14 +1674,14 @@ def test_draw_slm_mask_in_ising( seq1.draw( draw_qubit_det=True, draw_interp_pts=False, mode="output" ) # Drawing Sequence with only a DMM - assert len(record) == 7 + assert len(record) == 9 assert np.all( str(record[i].message).startswith( "No modulation bandwidth defined" ) - for i in range(6) + for i in range(len(record) - 1) ) - assert str(record[6].message).startswith( + assert str(record[-1].message).startswith( "Can't display modulated quantities per qubit" ) seq1.draw(mode, draw_qubit_det=draw_qubit_det, draw_interp_pts=False) diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index 0c8376784..19cb9fd72 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -24,7 +24,7 @@ import pulser_simulation from pulser.channels.dmm import DMM from pulser.devices import Device, MockDevice -from pulser.pulse import Pulse +from pulser.pulse import PHASE_PRECISION, Pulse from pulser.register.mappable_reg import MappableRegister from pulser.register.register_layout import RegisterLayout from pulser.sampler import sample @@ -174,12 +174,16 @@ def test_modulation(mod_seq: pulser.Sequence) -> None: input_ch_samples = input_samples.channel_samples["ch0"] output_ch_samples = mod_samples.channel_samples["ch0"] - for qty in ("amp", "det", "phase"): + for qty in ("amp", "det", "phase", "centered_phase"): np.testing.assert_array_equal( getattr(input_ch_samples.modulate(chan), qty), getattr(output_ch_samples, qty), ) + # input samples don't have a custom centered phase, output samples do + assert input_ch_samples._centered_phase is None + assert output_ch_samples._centered_phase is not None + def test_modulation_local(mod_device): seq = pulser.Sequence(pulser.Register.square(2), mod_device) @@ -384,9 +388,13 @@ def test_samples_repr(seq_rydberg): ) -def test_extend_duration(seq_rydberg): +@pytest.mark.parametrize("with_custom_centered_phase", [False, True]) +def test_extend_duration(seq_rydberg, with_custom_centered_phase): samples = sample(seq_rydberg) short, long = samples.samples_list + if with_custom_centered_phase: + short = replace(short, _centered_phase=short.centered_phase) + long = replace(long, _centered_phase=long.centered_phase) assert short.duration < long.duration assert short.extend_duration(short.duration).duration == short.duration with pytest.raises( @@ -396,7 +404,7 @@ def test_extend_duration(seq_rydberg): extended_short = short.extend_duration(long.duration) assert extended_short.duration == long.duration - for qty in ("amp", "det", "phase"): + for qty in ("amp", "det", "phase", "centered_phase"): new_qty_samples = getattr(extended_short, qty) old_qty_samples = getattr(short, qty) np.testing.assert_array_equal( @@ -404,7 +412,7 @@ def test_extend_duration(seq_rydberg): ) np.testing.assert_equal( new_qty_samples[short.duration :], - old_qty_samples[-1] if qty == "phase" else 0.0, + old_qty_samples[-1] if "phase" in qty else 0.0, ) assert extended_short.slots == short.slots @@ -445,9 +453,36 @@ def test_phase_sampling(mod_device): expected_phase[transition2_3:transition3_4] = 3.0 expected_phase[transition3_4:] = 4.0 - got_phase = sample(seq).channel_samples["ch0"].phase + got_phase = (ch_samples_ := sample(seq).channel_samples["ch0"]).phase np.testing.assert_array_equal(expected_phase, got_phase) + # Test centered phase + expected_phase[expected_phase > np.pi] -= 2 * np.pi + np.testing.assert_array_equal(expected_phase, ch_samples_.centered_phase) + + +@pytest.mark.parametrize("off_center", [False, True]) +def test_phase_modulation(off_center): + start_phase = np.pi / 2 + np.pi * off_center + phase1 = pulser.RampWaveform(400, start_phase, 0) + phase2 = pulser.BlackmanWaveform(500, np.pi) + phase3 = pulser.InterpolatedWaveform(500, [0, 11, 1, 5]) + full_phase = pulser.CompositeWaveform(phase1, phase2, phase3) + pulse = Pulse.ArbitraryPhase( + pulser.ConstantWaveform(full_phase.duration, 1), full_phase + ) + + seq = pulser.Sequence(pulser.Register.square(1), pulser.MockDevice) + seq.declare_channel("rydberg_global", "rydberg_global") + seq.add(pulse, "rydberg_global") + seq_samples = sample(seq).channel_samples["rydberg_global"] + + np.testing.assert_allclose( + seq_samples.phase_modulation + 2 * np.pi * off_center, + full_phase.samples, + atol=PHASE_PRECISION, + ) + @pytest.mark.parametrize("modulation", [True, False]) @pytest.mark.parametrize("draw_phase_area", [True, False]) diff --git a/tests/test_waveforms.py b/tests/test_waveforms.py index 31bbb4a37..bdfc7bf45 100644 --- a/tests/test_waveforms.py +++ b/tests/test_waveforms.py @@ -148,7 +148,7 @@ def test_composite(): wf = CompositeWaveform(blackman, constant) msg = ( "BlackmanWaveform(40 ns, Area: 3.14), " - + "ConstantWaveform(100 ns, -3 rad/µs)" + + "ConstantWaveform(100 ns, -3)" ) assert wf.__str__() == f"Composite({msg})" assert wf.__repr__() == f"CompositeWaveform(140 ns, [{msg}])" diff --git a/tutorials/advanced_features/Backends for Sequence Execution.ipynb b/tutorials/advanced_features/Backends for Sequence Execution.ipynb index 453136c41..ce5b6e53b 100644 --- a/tutorials/advanced_features/Backends for Sequence Execution.ipynb +++ b/tutorials/advanced_features/Backends for Sequence Execution.ipynb @@ -28,7 +28,7 @@ "Although the backend interface nearly doesn't change between backends, some will unavoidably enforce more restrictions on the sequence being executed or require extra steps. In particular, there are two questions to answer:\n", "\n", "1. **Is it local or remote?** Execution on remote backends requires a working remote connection. For now, this is only available through `pulser_pasqal.PasqalCloud`.\n", - "2. **Is it a QPU or an Emulator?** For QPU execution, there are extra constraints on the sequence to take into account.\n", + "2. **Is it a QPU or an Emulator?** For QPU execution, there are extra constraints on the sequence to take into account. Nonetheless, we can still enforce the same constraints when using an Emulator by setting `mimic_qpu=True`.\n", "\n", "### 1.1. Starting a remote connection\n", "\n",