From 97838e07f37344c542b567fa01f2c00196e5c851 Mon Sep 17 00:00:00 2001 From: a-corni Date: Tue, 23 May 2023 12:12:07 +0200 Subject: [PATCH 01/19] Bump version to 0.14dev0 --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 54d1a4f2a..43f0d5936 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.13.0 +0.14dev0 From b012b766c41463c729864da9a8408ac67a851847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Fri, 26 May 2023 16:50:43 +0200 Subject: [PATCH 02/19] Adding backends to create a uniform interface for sequence execution (#518) * WIP: Preliminary backend classes * Set the Backend and CloudBackend interface * Restructure the backend module * Create classes for configuration * Preliminart QutipBackend implementation * Move sequence validation to Backend * Cloud -> Remote * Defining the Results container class * Defining the RemoteResults object * Defining QPUBackend * Documenting QutipBackend * Adding helper methods to Sequence * Make PasqalCloud a RemoteConnection child class * Defining the Pasqal EMU backends * Relaxing back `Sequence.measure()` accepted bases * Import sorting * Defines NoiseModel and combines it with SimConfig * Converting EmulatorConfig into pasqal_cloud config * Move device validation and deprecate PasqalCloud methods * Typing * Add evaluation_times = "Final" option * UTs for Backend and NoiseModel * Relax return type of `Backend.run()` * Add UTs for new Sequence methods * Test new Sequence methods * Complete PasqalCloud UTs * Add tests for Pasqal Emulator backends * Tests for QPUBackend * Add tests for QutipBackend * Typing and import sorting * Add checks to EmulatorConfig * Use `QutipEmulator` in `QutipBackend` * Change type hint for job parameters * Addressing review comments * Change removal version for deprecation * Minor review suggestion --- pulser-core/pulser/backend/__init__.py | 14 + pulser-core/pulser/backend/abc.py | 45 +++ pulser-core/pulser/backend/config.py | 125 ++++++++ pulser-core/pulser/backend/noise_model.py | 205 +++++++++++++ pulser-core/pulser/backend/qpu.py | 65 +++++ pulser-core/pulser/backend/remote.py | 147 ++++++++++ pulser-core/pulser/result.py | 33 ++- pulser-core/pulser/sequence/sequence.py | 30 +- pulser-pasqal/pulser_pasqal/backends.py | 158 ++++++++++ pulser-pasqal/pulser_pasqal/pasqal_cloud.py | 149 +++++++++- .../pulser_simulation/qutip_backend.py | 78 +++++ .../pulser_simulation/simconfig.py | 211 ++++++-------- .../pulser_simulation/simresults.py | 35 +-- .../pulser_simulation/simulation.py | 8 +- tests/test_backend.py | 271 ++++++++++++++++++ tests/test_pasqal.py | 247 +++++++++++++++- tests/test_qutip_backend.py | 55 ++++ tests/test_sequence.py | 23 +- tests/test_sequence_sampler.py | 2 +- tests/test_simconfig.py | 67 ++--- 20 files changed, 1746 insertions(+), 222 deletions(-) create mode 100644 pulser-core/pulser/backend/__init__.py create mode 100644 pulser-core/pulser/backend/abc.py create mode 100644 pulser-core/pulser/backend/config.py create mode 100644 pulser-core/pulser/backend/noise_model.py create mode 100644 pulser-core/pulser/backend/qpu.py create mode 100644 pulser-core/pulser/backend/remote.py create mode 100644 pulser-pasqal/pulser_pasqal/backends.py create mode 100644 pulser-simulation/pulser_simulation/qutip_backend.py create mode 100644 tests/test_backend.py create mode 100644 tests/test_qutip_backend.py diff --git a/pulser-core/pulser/backend/__init__.py b/pulser-core/pulser/backend/__init__.py new file mode 100644 index 000000000..ac8e7e552 --- /dev/null +++ b/pulser-core/pulser/backend/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Classes for backend execution.""" diff --git a/pulser-core/pulser/backend/abc.py b/pulser-core/pulser/backend/abc.py new file mode 100644 index 000000000..56d612fa2 --- /dev/null +++ b/pulser-core/pulser/backend/abc.py @@ -0,0 +1,45 @@ +# Copyright 2023 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. +"""Base class for the backend interface.""" +from __future__ import annotations + +import typing +from abc import ABC, abstractmethod + +from pulser.result import Result +from pulser.sequence import Sequence + +Results = typing.Sequence[Result] + + +class Backend(ABC): + """The backend abstract base class.""" + + def __init__(self, sequence: Sequence) -> None: + """Starts a new backend instance.""" + self.validate_sequence(sequence) + self._sequence = sequence + + @abstractmethod + def run(self) -> Results | typing.Sequence[Results]: + """Executes the sequence on the backend.""" + pass + + def validate_sequence(self, sequence: Sequence) -> None: + """Validates a sequence prior to submission.""" + if not isinstance(sequence, Sequence): + raise TypeError( + "'sequence' should be a `Sequence` instance" + f", not {type(sequence)}." + ) diff --git a/pulser-core/pulser/backend/config.py b/pulser-core/pulser/backend/config.py new file mode 100644 index 000000000..eb7f27cfb --- /dev/null +++ b/pulser-core/pulser/backend/config.py @@ -0,0 +1,125 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines the backend configuration classes.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, Sequence, get_args + +import numpy as np + +from pulser.backend.noise_model import NoiseModel + +EVAL_TIMES_LITERAL = Literal["Full", "Minimal", "Final"] + + +@dataclass +class BackendConfig: + """The base backend configuration. + + Attributes: + backend_options: A dictionary of backend specific options. + """ + + backend_options: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class EmulatorConfig(BackendConfig): + """The configuration for emulator backends. + + Attributes: + backend_options: A dictionary of backend-specific options. + sampling_rate: The fraction of samples to extract from the pulse + sequence for emulation. + evaluation_times: The times at which results are returned. Choose + between: + + - "Full": The times are set to be the ones used to define the + Hamiltonian to the solver. + + - "Minimal": The times are set to only include initial and final + times. + + - "Final": Returns only the result at the end of the sequence. + + - A list of times in µs if you wish to only include those specific + times. + + - A float to act as a sampling rate for the resulting state. + initial_state: The initial state from which emulation starts. + Choose between: + + - "all-ground" for all atoms in the ground state + - An array of floats with a shape compatible with the system + with_modulation: Whether to emulate the sequence with the programmed + input or the expected output. + noise_model: An optional noise model to emulate the sequence with. + """ + + sampling_rate: float = 1.0 + evaluation_times: float | Sequence[float] | EVAL_TIMES_LITERAL = "Full" + initial_state: Literal["all-ground"] | Sequence[complex] = "all-ground" + with_modulation: bool = False + noise_model: NoiseModel = field(default_factory=NoiseModel) + + def __post_init__(self) -> None: + if not (0 < self.sampling_rate <= 1.0): + raise ValueError( + "The sampling rate (`sampling_rate` = " + f"{self.sampling_rate}) must be greater than 0 and " + "less than or equal to 1." + ) + + if isinstance(self.evaluation_times, str): + if self.evaluation_times not in get_args(EVAL_TIMES_LITERAL): + raise ValueError( + "If provided as a string, 'evaluation_times' must be one " + f"of the following options: {get_args(EVAL_TIMES_LITERAL)}" + ) + elif isinstance(self.evaluation_times, float): + if not (0 < self.evaluation_times <= 1.0): + raise ValueError( + "If provided as a float, 'evaluation_times' must be" + " greater than 0 and less than or equal to 1." + ) + elif isinstance(self.evaluation_times, (list, tuple, np.ndarray)): + if np.min(self.evaluation_times, initial=0) < 0: + raise ValueError( + "If provided as a sequence of values, " + "'evaluation_times' must not contain negative values." + ) + else: + raise TypeError( + f"'{type(self.evaluation_times)}' is not a valid" + " type for 'evaluation_times'." + ) + + if isinstance(self.initial_state, str): + if self.initial_state != "all-ground": + raise ValueError( + "If provided as a string, 'initial_state' must be" + " 'all-ground'." + ) + elif not isinstance(self.initial_state, (tuple, list, np.ndarray)): + raise TypeError( + f"'{type(self.initial_state)}' is not a valid type for" + " 'initial_state'." + ) + + if not isinstance(self.noise_model, NoiseModel): + raise TypeError( + "'noise_model' must be a NoiseModel instance," + f" not {type(self.noise_model)}." + ) diff --git a/pulser-core/pulser/backend/noise_model.py b/pulser-core/pulser/backend/noise_model.py new file mode 100644 index 000000000..ca862394a --- /dev/null +++ b/pulser-core/pulser/backend/noise_model.py @@ -0,0 +1,205 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines a noise model class for emulator backends.""" +from __future__ import annotations + +from dataclasses import dataclass, field, fields +from typing import Literal, get_args + +import numpy as np + +NOISE_TYPES = Literal[ + "doppler", "amplitude", "SPAM", "dephasing", "depolarizing", "eff_noise" +] + + +@dataclass +class NoiseModel: + """Specifies the noise model parameters for emulation. + + Select the desired noise types in `noise_types` and, if necessary, + modifiy the default values of related parameters. + Non-specified parameters will have reasonable default value which + is only taken into account when the related noise type is selected. + + Args: + noise_types: Noise types to include in the emulation. Available + options: + + - "dephasing": Random phase (Z) flip (parametrized + by `dephasing_prob`). + - "depolarizing": Quantum noise where the state is + turned into a mixed state I/2 with probability + `depolarizing_prob`. + - "eff_noise": General effective noise channel defined by + the set of collapse operators `eff_noise_opers` + and the corresponding probability distribution + `eff_noise_probs`. + - "doppler": Local atom detuning due to termal motion of the + atoms and Doppler effect with respect to laser frequency. + Parametrized by the `temperature` field. + - "amplitude": Gaussian damping due to finite laser waist and + laser amplitude fluctuations. Parametrized by `laser_waist` + and `amp_sigma`. + - "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. + 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. + temperature: Temperature, set in µK, of the atoms in the array. + Also sets the standard deviation of the speed of the atoms. + laser_waist: Waist of the gaussian laser, set in µm, for global + pulses. + amp_sigma: Dictates the fluctuations in amplitude as a standard + deviation of a normal distribution centered in 1. + dephasing_prob: The probability of a dephasing error occuring. + depolarizing_prob: The probability of a depolarizing error occuring. + eff_noise_probs: The probability associated to each effective noise + operator. + eff_noise_opers: The operators for the effective noise model. The + first operator must be the identity. + """ + + noise_types: tuple[NOISE_TYPES, ...] = () + runs: int = 15 + samples_per_run: int = 5 + state_prep_error: float = 0.005 + p_false_pos: float = 0.01 + p_false_neg: float = 0.05 + temperature: float = 50.0 + laser_waist: float = 175.0 + amp_sigma: float = 5e-2 + dephasing_prob: float = 0.05 + depolarizing_prob: float = 0.05 + eff_noise_probs: list[float] = field(default_factory=list) + eff_noise_opers: list[np.ndarray] = field(default_factory=list) + + def __post_init__(self) -> None: + strict_positive = { + "runs", + "samples_per_run", + "temperature", + "laser_waist", + } + probability_like = { + "state_prep_error", + "p_false_pos", + "p_false_neg", + "dephasing_prob", + "depolarizing_prob", + "amp_sigma", + } + # The two share no common terms + assert not strict_positive.intersection(probability_like) + + for f in fields(self): + is_valid = True + param = f.name + value = getattr(self, param) + if param in strict_positive: + is_valid = value > 0 + comp = "greater than zero" + elif param in probability_like: + is_valid = 0 <= value <= 1 + comp = ( + "greater than or equal to zero and smaller than " + "or equal to one" + ) + if not is_valid: + raise ValueError(f"'{param}' must be {comp}, not {value}.") + + self._check_noise_types() + self._check_eff_noise() + + def _check_noise_types(self) -> None: + for noise_type in self.noise_types: + if noise_type not in get_args(NOISE_TYPES): + raise ValueError( + f"'{noise_type}' is not a valid noise type. " + + "Valid noise types: " + + ", ".join(get_args(NOISE_TYPES)) + ) + dephasing_on = "dephasing" in self.noise_types + depolarizing_on = "depolarizing" in self.noise_types + eff_noise_on = "eff_noise" in self.noise_types + eff_noise_conflict = dephasing_on + depolarizing_on + eff_noise_on > 1 + if eff_noise_conflict: + raise NotImplementedError( + "Depolarizing, dephasing and effective noise channels" + "cannot be simultaneously selected." + ) + + def _check_eff_noise(self) -> None: + if len(self.eff_noise_opers) != len(self.eff_noise_probs): + raise ValueError( + f"The operators list length({len(self.eff_noise_opers)}) " + "and probabilities list length" + f"({len(self.eff_noise_probs)}) must be equal." + ) + for prob in self.eff_noise_probs: + if not isinstance(prob, float): + raise TypeError( + "eff_noise_probs is a list of floats," + f" it must not contain a {type(prob)}." + ) + + if "eff_noise" not in self.noise_types: + # Stop here if effective noise is not selected + return + + if not self.eff_noise_opers or not self.eff_noise_probs: + raise ValueError( + "The general noise parameters have not been filled." + ) + + prob_distr = np.array(self.eff_noise_probs) + lower_bound = np.any(prob_distr < 0.0) + upper_bound = np.any(prob_distr > 1.0) + sum_p = not np.isclose(sum(prob_distr), 1.0) + + if sum_p or lower_bound or upper_bound: + raise ValueError( + "The distribution given is not a probability distribution." + ) + + # Check the validity of operators + for operator in self.eff_noise_opers: + # type checking + if not isinstance(operator, np.ndarray): + raise TypeError(f"{operator} is not a Numpy array.") + if operator.shape != (2, 2): + raise NotImplementedError( + "Operator's shape must be (2,2) " f"not {operator.shape}." + ) + # Identity position + identity = np.eye(2) + if np.any(self.eff_noise_opers[0] != identity): + raise NotImplementedError( + "You must put the identity matrix at the " + "beginning of the operator list." + ) + # Completeness relation checking + sum_op = np.zeros((2, 2), dtype=complex) + for prob, op in zip(self.eff_noise_probs, self.eff_noise_opers): + sum_op += prob * op @ op.conj().transpose() + + if not np.all(np.isclose(sum_op, identity)): + raise ValueError( + "The completeness relation is not verified." + f" Ended up with {sum_op} instead of {identity}." + ) diff --git a/pulser-core/pulser/backend/qpu.py b/pulser-core/pulser/backend/qpu.py new file mode 100644 index 000000000..f2a141d3f --- /dev/null +++ b/pulser-core/pulser/backend/qpu.py @@ -0,0 +1,65 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines the backend class for QPU execution.""" +from __future__ import annotations + +from typing import cast + +from pulser import Sequence +from pulser.backend.remote import JobParams, RemoteBackend, RemoteResults +from pulser.devices import Device + + +class QPUBackend(RemoteBackend): + """Backend for sequence execution on a QPU.""" + + def run(self, job_params: list[JobParams] = []) -> RemoteResults: + """Runs the sequence on the remote QPU and returns the result. + + Args: + job_params: A list of parameters for each job to execute. Each + mapping must contain a defined 'runs' field specifying + the number of times to run the same sequence. If the sequence + is parametrized, the values for all the variables necessary + to build the sequence must be given in it's own mapping, for + each job, under the 'variables' field. + + Returns: + The results, which can be accessed once all sequences have been + successfully executed. + """ + suffix = " when executing a sequence on a real QPU." + if not job_params: + raise ValueError("'job_params' must be specified" + suffix) + if any("runs" not in j for j in job_params): + raise ValueError( + "All elements of 'job_params' must specify 'runs'" + suffix + ) + results = self._connection.submit( + self._sequence, job_params=job_params + ) + 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 new file mode 100644 index 000000000..0741dcf49 --- /dev/null +++ b/pulser-core/pulser/backend/remote.py @@ -0,0 +1,147 @@ +# Copyright 2023 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. +"""Base classes for remote backend execution.""" +from __future__ import annotations + +import typing +from abc import ABC, abstractmethod +from enum import Enum, auto +from typing import Any, TypedDict + +from pulser.backend.abc import Backend +from pulser.devices import Device +from pulser.result import Result, Results +from pulser.sequence import Sequence + + +class JobParams(TypedDict, total=False): + """The parameters for an individual job running on a backend.""" + + runs: int + variables: dict[str, Any] + + +class SubmissionStatus(Enum): + """Status of a remote submission.""" + + PENDING = auto() + RUNNING = auto() + DONE = auto() + CANCELED = auto() + TIMED_OUT = auto() + ERROR = auto() + PAUSED = auto() + + +class RemoteResultsError(Exception): + """Error raised when fetching remote results fails.""" + + pass + + +class RemoteResults(Results): + """A collection of results obtained through a remote connection. + + Args: + submission_id: The ID that identifies the submission linked to + the results. + connection: The remote connection over which to get the submission's + status and fetch the results. + """ + + def __init__(self, submission_id: str, connection: RemoteConnection): + """Instantiates a new collection of remote results.""" + self._submission_id = submission_id + self._connection = connection + + @property + def results(self) -> tuple[Result, ...]: + """The actual results, obtained after execution is done.""" + return self._results + + def get_status(self) -> SubmissionStatus: + """Gets the status of the remote submission.""" + return self._connection._get_submission_status(self._submission_id) + + def __getattr__(self, name: str) -> Any: + if name == "_results": + status = self.get_status() + if status == SubmissionStatus.DONE: + self._results = tuple( + self._connection._fetch_result(self._submission_id) + ) + return self._results + raise RemoteResultsError( + "The results are not available. The submission's status is " + f"{str(status)}." + ) + raise AttributeError( + f"'RemoteResults' object has no attribute '{name}'." + ) + + +class RemoteConnection(ABC): + """The abstract base class for a remote connection.""" + + @abstractmethod + def submit( + self, sequence: Sequence, **kwargs: Any + ) -> RemoteResults | tuple[RemoteResults, ...]: + """Submit a job for execution.""" + pass + + @abstractmethod + def _fetch_result(self, submission_id: str) -> typing.Sequence[Result]: + """Fetches the results of a completed submission.""" + pass + + @abstractmethod + def _get_submission_status(self, submission_id: str) -> SubmissionStatus: + """Gets the status of a submission from its ID. + + Not all SubmissionStatus values must be covered, but at least + SubmissionStatus.DONE is expected. + """ + pass + + def fetch_available_devices(self) -> dict[str, Device]: + """Fetches the available devices through this connection.""" + raise NotImplementedError( + "Unable to fetch the available devices through this " + "remote connection." + ) + + +class RemoteBackend(Backend): + """A backend for sequence execution through a remote connection. + + 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 remote backend instance.""" + super().__init__(sequence) + if not isinstance(connection, RemoteConnection): + raise TypeError( + "'connection' must be a valid RemoteConnection instance." + ) + self._connection = connection diff --git a/pulser-core/pulser/result.py b/pulser-core/pulser/result.py index 35db67750..f62812b02 100644 --- a/pulser-core/pulser/result.py +++ b/pulser-core/pulser/result.py @@ -14,10 +14,12 @@ """Classes to store measurement results.""" from __future__ import annotations +import collections.abc +import typing from abc import ABC, abstractmethod from collections import Counter from dataclasses import dataclass -from typing import Any +from typing import Any, TypeVar, overload import matplotlib.pyplot as plt import numpy as np @@ -155,3 +157,32 @@ def _weights(self) -> np.ndarray: for bitstr, counts in self.bitstring_counts.items(): weights[int(bitstr, base=2)] = counts / self.n_samples return weights / sum(weights) + + +ResultType = TypeVar("ResultType", bound=Result) + + +class Results(typing.Sequence[ResultType]): + """An immutable sequence of results.""" + + _results: tuple[ResultType, ...] + + @overload + def __getitem__(self, key: int) -> ResultType: + pass + + @overload + def __getitem__(self, key: slice) -> tuple[ResultType, ...]: + pass + + def __getitem__( + self, key: int | slice + ) -> ResultType | tuple[ResultType, ...]: + return self._results[key] + + def __len__(self) -> int: + return len(self._results) + + def __iter__(self) -> collections.abc.Iterator[ResultType]: + for res in self._results: + yield res diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index a44858739..de5191409 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -291,11 +291,25 @@ def is_register_mappable(self) -> bool: def is_measured(self) -> bool: """States whether the sequence has been measured.""" return ( - self._is_measured + bool(self._param_measurement) if self.is_parametrized() else hasattr(self, "_measurement") ) + def get_measurement_basis(self) -> str: + """Gets the sequence's measurement basis. + + Raises: + RuntimeError: When the sequence has not been measured. + """ + if not self.is_measured(): + raise RuntimeError("The sequence has not been measured.") + return ( + self._param_measurement + if self.is_parametrized() + else self._measurement + ) + @seq_decorators.screen def get_duration( self, channel: Optional[str] = None, include_fall_time: bool = False @@ -317,6 +331,10 @@ def get_duration( return self._schedule.get_duration(channel, include_fall_time) + def get_addressed_bases(self) -> tuple[str, ...]: + """Returns the bases addressed by the declared channels.""" + return tuple(self._basis_ref) + @seq_decorators.screen def current_phase_ref( self, qubit: QubitId, basis: str = "digital" @@ -1030,9 +1048,15 @@ def measure(self, basis: str = "ground-rydberg") -> None: "selected device and operation mode. The " "available options are: " + ", ".join(list(available)) ) + elif basis not in self.get_addressed_bases(): + warnings.warn( + f"The desired measurement basis '{basis}' is not being " + "addressed by any channel in the sequence.", + stacklevel=2, + ) if self.is_parametrized(): - self._is_measured = True + self._param_measurement = basis else: self._measurement = basis @@ -1628,7 +1652,7 @@ def _reset_parametrized(self) -> None: """Resets all attributes related to parametrization.""" # Signals the sequence as actively "building" ie not parametrized self._building = True - self._is_measured = False + self._param_measurement = "" self._variables = {} self._to_build_calls = [] diff --git a/pulser-pasqal/pulser_pasqal/backends.py b/pulser-pasqal/pulser_pasqal/backends.py new file mode 100644 index 000000000..4dbf68456 --- /dev/null +++ b/pulser-pasqal/pulser_pasqal/backends.py @@ -0,0 +1,158 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines Pasqal specific backends.""" +from __future__ import annotations + +from dataclasses import fields +from typing import ClassVar + +import pasqal_cloud + +import pulser +from pulser.backend.config import EmulatorConfig +from pulser.backend.remote import JobParams, RemoteBackend, RemoteResults +from pulser_pasqal.pasqal_cloud import PasqalCloud + +DEFAULT_CONFIG_EMU_TN = EmulatorConfig( + evaluation_times="Final", sampling_rate=0.1 +) +DEFAULT_CONFIG_EMU_FREE = EmulatorConfig( + evaluation_times="Final", sampling_rate=0.25 +) + + +class PasqalEmulator(RemoteBackend): + """The base class for a Pasqal emulator backend.""" + + emulator: ClassVar[pasqal_cloud.EmulatorType] + default_config: ClassVar[EmulatorConfig] = EmulatorConfig() + configurable_fields: ClassVar[tuple[str, ...]] = ("backend_options",) + + def __init__( + self, + sequence: pulser.Sequence, + connection: PasqalCloud, + config: EmulatorConfig | None = None, + ) -> None: + """Initializes a new Pasqal emulator backend.""" + super().__init__(sequence, connection) + config_ = config or self.default_config + self._validate_config(config_) + self._config = config_ + if not isinstance(self._connection, PasqalCloud): + raise TypeError( + "The connection to the remote backend must be done" + " through a 'PasqalCloud' instance." + ) + + def run( + self, job_params: list[JobParams] | None = None + ) -> RemoteResults | tuple[RemoteResults, ...]: + """Executes on the emulator backend through the Pasqal Cloud. + + Args: + job_params: An optional list of parameters for each job to execute. + Must be provided only when the sequence is parametrized as + a list of mappings, where each mapping contains one mapping + of variable names to values under the 'variables' field. + + Returns: + The results, which can be accessed once all sequences have been + successfully executed. + + """ + needs_build = ( + self._sequence.is_parametrized() + or self._sequence.is_register_mappable() + ) + if job_params is None and needs_build: + raise ValueError( + "When running a sequence that requires building, " + "'job_params' must be provided." + ) + elif job_params and not needs_build: + raise ValueError( + "'job_params' cannot be provided when running built " + "sequences on an emulator backend." + ) + + return self._connection.submit( + self._sequence, + job_params=job_params, + emulator=self.emulator, + config=self._config, + ) + + def _validate_config(self, config: EmulatorConfig) -> None: + if not isinstance(config, EmulatorConfig): + raise TypeError( + "'config' must be of type 'EmulatorConfig', " + f"not {type(config)}." + ) + for field in fields(config): + if field.name in self.configurable_fields: + continue + default_value = getattr(self.default_config, field.name) + if getattr(config, field.name) != default_value: + raise NotImplementedError( + f"'EmulatorConfig.{field.name}' is not configurable in " + "this backend. It should not be changed from its default " + f"value of '{default_value}'." + ) + + +class EmuTNBackend(PasqalEmulator): + """An emulator backend using tensor network simulation. + + Configurable fields in EmulatorConfig: + - sampling_rate + - backend_options: + - precision (str): The precision of the simulation. Can be "low", + "normal" or "high". Defaults to "normal". + - max_bond_dim (int): The maximum bond dimension of the Matrix + Product State (MPS). Defaults to 500. + + All other parameters should not be changed from their default values. + + Args: + sequence: The sequence to send to the backend. + connection: An open PasqalCloud connection. + config: An EmulatorConfig to configure the backend. If not provided, + the default configuration is used. + """ + + emulator = pasqal_cloud.EmulatorType.EMU_TN + default_config = DEFAULT_CONFIG_EMU_TN + configurable_fields = ("backend_options", "sampling_rate") + + +class EmuFreeBackend(PasqalEmulator): + """An emulator backend using free Hamiltonian time evolution. + + Configurable fields in EmulatorConfig: + - backend_options: + - with_noise (bool): Whether to add noise to the simulation. + Defaults to False. + + All other parameters should not be changed from their default values. + + Args: + sequence: The sequence to send to the backend. + connection: An open PasqalCloud connection. + config: An EmulatorConfig to configure the backend. If not provided, + the default configuration is used. + """ + + emulator = pasqal_cloud.EmulatorType.EMU_FREE + default_config = DEFAULT_CONFIG_EMU_FREE diff --git a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py index b6125537b..95178f010 100644 --- a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py +++ b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py @@ -14,16 +14,37 @@ """Allows to connect to PASQAL's cloud platform to run sequences.""" from __future__ import annotations -from typing import Any, Optional +import copy +import warnings +from dataclasses import fields +from typing import Any, Optional, Type import pasqal_cloud +from pasqal_cloud.device.configuration import ( + BaseConfig, + EmuFreeConfig, + EmuTNConfig, +) from pulser import Sequence +from pulser.backend.config import EmulatorConfig +from pulser.backend.remote import ( + JobParams, + RemoteConnection, + RemoteResults, + SubmissionStatus, +) from pulser.devices import Device +from pulser.result import Result, SampledResult from pulser_pasqal.job_parameters import JobParameters +EMU_TYPE_TO_CONFIG: dict[pasqal_cloud.EmulatorType, Type[BaseConfig]] = { + pasqal_cloud.EmulatorType.EMU_FREE: EmuFreeConfig, + pasqal_cloud.EmulatorType.EMU_TN: EmuTNConfig, +} -class PasqalCloud: + +class PasqalCloud(RemoteConnection): """Manager of the connection to PASQAL's cloud platform. The cloud connection enables to run sequences on simulators or on real @@ -51,6 +72,107 @@ def __init__( **kwargs, ) + def submit(self, sequence: Sequence, **kwargs: Any) -> RemoteResults: + """Submits the sequence for execution on a remote Pasqal backend.""" + if not sequence.is_measured(): + bases = sequence.get_addressed_bases() + if len(bases) != 1: + raise ValueError( + "The measurement basis can't be implicitly determined " + "for a sequence not addressing a single basis." + ) + # The copy prevents changing the input sequence + sequence = copy.deepcopy(sequence) + sequence.measure(bases[0]) + + emulator = kwargs.get("emulator", None) + job_params: list[JobParams] = kwargs.get("job_params", []) + if emulator is None: + 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(): + raise ValueError( + "The device used in the sequence does not match any " + "of the devices currently available through the remote " + "connection." + ) + + 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 + ) + + batch = self._sdk_connection.create_batch( + serialized_sequence=sequence.to_abstract_repr(), + jobs=job_params or [], # type: ignore[arg-type] + emulator=emulator, + configuration=configuration, + wait=False, + fetch_results=False, + ) + return RemoteResults(batch.id, self) + + def _fetch_result(self, submission_id: str) -> tuple[Result, ...]: + # For now, the results are always sampled results + batch = self._sdk_connection.get_batch( + id=submission_id, fetch_results=True + ) + seq_builder = Sequence.from_abstract_repr(batch.sequence_builder) + reg = seq_builder.get_register(include_mappable=True) + all_qubit_ids = reg.qubit_ids + meas_basis = seq_builder.get_measurement_basis() + + results = [] + for job in batch.jobs.values(): + vars = job.variables + size: int | None = None + if vars and "qubits" in vars: + size = len(vars["qubits"]) + assert job.result is not None, "Failed to fetch the results." + results.append( + SampledResult( + atom_order=all_qubit_ids[slice(size)], + meas_basis=meas_basis, + bitstring_counts=job.result, + ) + ) + return tuple(results) + + def _get_submission_status(self, submission_id: str) -> SubmissionStatus: + """Gets the status of a submission from its ID.""" + batch = self._sdk_connection.get_batch( + id=submission_id, fetch_results=False + ) + return SubmissionStatus[batch.status] + + def _convert_configuration( + self, + config: EmulatorConfig | None, + emulator: pasqal_cloud.EmulatorType | None, + ) -> pasqal_cloud.BaseConfig | None: + """Converts a backend configuration into a pasqal_cloud.BaseConfig.""" + if emulator is None or config is None: + return None + emu_cls = EMU_TYPE_TO_CONFIG[emulator] + backend_options = config.backend_options.copy() + pasqal_config_kwargs = {} + for field in fields(emu_cls): + pasqal_config_kwargs[field.name] = backend_options.pop( + field.name, field.default + ) + # We pass the remaining backend options to "extra_config" + if backend_options: + pasqal_config_kwargs["extra_config"] = backend_options + if emulator == pasqal_cloud.EmulatorType.EMU_TN: + pasqal_config_kwargs["dt"] = 1.0 / config.sampling_rate + + return emu_cls(**pasqal_config_kwargs) + def create_batch( self, seq: Sequence, @@ -77,6 +199,17 @@ def create_batch( Returns: Batch: The new batch that has been created in the database. """ + with warnings.catch_warnings(): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + "'PasqalCloud.create_batch()' is deprecated and will be " + "removed after v0.14. To submit jobs to the Pasqal Cloud, " + "use one of the remote backends (eg QPUBackend, EmuTNBacked," + " EmuFreeBackend) with an open PasqalCloud() connection.", + category=DeprecationWarning, + stacklevel=2, + ) + if emulator is None and not isinstance(seq.device, Device): raise TypeError( "To be sent to a real QPU, the device of the sequence " @@ -107,6 +240,18 @@ def get_batch( Returns: Batch: The batch stored in the database. """ + with warnings.catch_warnings(): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + "'PasqalCloud.get_batch()' is deprecated and will be removed " + "after v0.14. To retrieve the results from a job executed " + "through the Pasqal Cloud, use the RemoteResults instance " + "returned after calling run() on one of the remote backends" + " (eg QPUBackend, EmuTNBacked, EmuFreeBackend) with an open " + "PasqalCloud() connection.", + category=DeprecationWarning, + stacklevel=2, + ) return self._sdk_connection.get_batch( id=id, fetch_results=fetch_results ) diff --git a/pulser-simulation/pulser_simulation/qutip_backend.py b/pulser-simulation/pulser_simulation/qutip_backend.py new file mode 100644 index 000000000..01ad96a91 --- /dev/null +++ b/pulser-simulation/pulser_simulation/qutip_backend.py @@ -0,0 +1,78 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Defines the QutipBackend class.""" +from __future__ import annotations + +from typing import Any + +from pulser import Sequence +from pulser.backend.abc import Backend +from pulser.backend.config import EmulatorConfig +from pulser_simulation.simconfig import SimConfig +from pulser_simulation.simresults import SimulationResults +from pulser_simulation.simulation import QutipEmulator + + +class QutipBackend(Backend): + """A backend for emulating the sequences using qutip. + + Args: + sequence: The sequence to emulate. + config: The configuration for the Qutip emulator. + """ + + def __init__( + self, sequence: Sequence, config: EmulatorConfig = EmulatorConfig() + ): + """Initializes a new QutipBackend.""" + super().__init__(sequence) + if not isinstance(config, EmulatorConfig): + raise TypeError( + "'config' must be of type 'EmulatorConfig', " + f"not {type(config)}." + ) + self._config = config + self._sim_obj = QutipEmulator.from_sequence( + sequence, + sampling_rate=self._config.sampling_rate, + config=SimConfig.from_noise_model(self._config.noise_model), + evaluation_times=self._config.evaluation_times, + with_modulation=self._config.with_modulation, + ) + self._sim_obj.set_initial_state(self._config.initial_state) + + def run( + self, progress_bar: bool = False, **qutip_options: Any + ) -> SimulationResults: + """Emulates the sequence using QuTiP's solvers. + + Args: + progress_bar: If True, the progress bar of QuTiP's + solver will be shown. If None or False, no text appears. + options: Used as arguments for qutip.Options(). If specified, will + override SimConfig solver_options. If no `max_step` value is + provided, an automatic one is calculated from the `Sequence`'s + schedule (half of the shortest duration among pulses and + delays). + Refer to the QuTiP docs_ for an overview of the parameters. + + .. _docs: https://bit.ly/3il9A2u + + + Returns: + SimulationResults: In particular, returns NoisyResults if the + noise model in EmulatorConfig requires it. + Otherwise, returns CoherentResults. + """ + return self._sim_obj.run(progress_bar=progress_bar, **qutip_options) diff --git a/pulser-simulation/pulser_simulation/simconfig.py b/pulser-simulation/pulser_simulation/simconfig.py index aa9c7816e..54f469aa0 100644 --- a/pulser-simulation/pulser_simulation/simconfig.py +++ b/pulser-simulation/pulser_simulation/simconfig.py @@ -16,11 +16,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal, Optional, Union, get_args +from math import sqrt +from typing import Any, Literal, Optional, Tuple, Type, TypeVar, Union, cast -import numpy as np import qutip +from pulser.backend.noise_model import NoiseModel + NOISE_TYPES = Literal[ "doppler", "amplitude", "SPAM", "dephasing", "depolarizing", "eff_noise" ] @@ -28,6 +30,8 @@ KB = 1.38e-23 # J/K KEFF = 8.7 # µm^-1 +T = TypeVar("T", bound="SimConfig") + @dataclass(frozen=True) class SimConfig: @@ -86,33 +90,77 @@ class SimConfig: eff_noise_probs: list[float] = field(default_factory=list, repr=False) eff_noise_opers: list[qutip.Qobj] = field(default_factory=list, repr=False) solver_options: Optional[qutip.Options] = None - spam_dict: dict[str, float] = field( - init=False, default_factory=dict, repr=False - ) - doppler_sigma: float = field( - init=False, default=KEFF * np.sqrt(KB * 50.0e-6 / MASS) - ) + + @classmethod + def from_noise_model(cls: Type[T], noise_model: NoiseModel) -> T: + """Creates a SimConfig from a NoiseModel.""" + return cls( + noise=noise_model.noise_types, + runs=noise_model.runs, + samples_per_run=noise_model.samples_per_run, + temperature=noise_model.temperature, + laser_waist=noise_model.laser_waist, + amp_sigma=noise_model.amp_sigma, + eta=noise_model.state_prep_error, + epsilon=noise_model.p_false_pos, + epsilon_prime=noise_model.p_false_neg, + dephasing_prob=noise_model.dephasing_prob, + depolarizing_prob=noise_model.depolarizing_prob, + eff_noise_probs=noise_model.eff_noise_probs, + eff_noise_opers=list(map(qutip.Qobj, noise_model.eff_noise_opers)), + ) + + def to_noise_model(self) -> NoiseModel: + """Creates a NoiseModel from the SimConfig.""" + return NoiseModel( + noise_types=cast(Tuple[NOISE_TYPES, ...], self.noise), + runs=self.runs, + samples_per_run=self.samples_per_run, + state_prep_error=self.eta, + p_false_pos=self.epsilon, + p_false_neg=self.epsilon_prime, + temperature=self.temperature * 1e6, # Converts back to µK + laser_waist=self.laser_waist, + amp_sigma=self.amp_sigma, + dephasing_prob=self.dephasing_prob, + depolarizing_prob=self.depolarizing_prob, + eff_noise_probs=self.eff_noise_probs, + eff_noise_opers=[op.full() for op in self.eff_noise_opers], + ) def __post_init__(self) -> None: - if not 0.0 <= self.amp_sigma < 1.0: - raise ValueError( - "The standard deviation in amplitude (amp_sigma=" - f"{self.amp_sigma}) must be greater than or equal" - " to 0. and smaller than 1." + # only one noise was given as argument : convert it to a tuple + if isinstance(self.noise, str): + self._change_attribute("noise", (self.noise,)) + + # Converts temperature from µK to K + if not isinstance(self.temperature, (int, float)): + raise TypeError( + f"'temperature' must be a float, not {type(self.temperature)}." ) - self._process_temperature() - self._change_attribute( - "spam_dict", - { - "eta": self.eta, - "epsilon": self.epsilon, - "epsilon_prime": self.epsilon_prime, - }, - ) - self._check_noise_types() + self._change_attribute("temperature", self.temperature * 1e-6) + + # Kept to show error messages with the right parameter names self._check_spam_dict() - self._calc_sigma_doppler() - self._check_eff_noise() + + self._check_eff_noise_opers_type() + + # Runs the noise model checks + self.to_noise_model() + + @property + def spam_dict(self) -> dict[str, float]: + """A dictionary combining the SPAM error parameters.""" + return { + "eta": self.eta, + "epsilon": self.epsilon, + "epsilon_prime": self.epsilon_prime, + } + + @property + def doppler_sigma(self) -> float: + """Standard deviation for Doppler shifting due to thermal motion.""" + return KEFF * sqrt(KB * self.temperature / MASS) def __str__(self, solver_options: bool = False) -> str: lines = [ @@ -155,113 +203,18 @@ def _check_spam_dict(self) -> None: + " greater than 0 and less than 1." ) - def _process_temperature(self) -> None: - # checks value of temperature field and converts it to K from muK - if self.temperature <= 0: - raise ValueError( - "Temperature field" - + f" (`temperature` = {self.temperature}) must be" - + " greater than 0." - ) - self._change_attribute("temperature", self.temperature * 1.0e-6) - - def _check_noise_types(self) -> None: - # only one noise was given as argument : convert it to a tuple - if isinstance(self.noise, str): - self._change_attribute("noise", (self.noise,)) - for noise_type in self.noise: - if noise_type not in get_args(NOISE_TYPES): - raise ValueError( - f"{noise_type} is not a valid noise type. " - + "Valid noise types: " - + ", ".join(get_args(NOISE_TYPES)) - ) - dephasing_on = "dephasing" in self.noise - depolarizing_on = "depolarizing" in self.noise - eff_noise_on = "eff_noise" in self.noise - eff_noise_conflict = dephasing_on + depolarizing_on + eff_noise_on > 1 - if eff_noise_conflict: - raise NotImplementedError( - "Depolarizing, dephasing and eff_noise channels" - "cannot be activated at the same time in" - " one simulation." - ) - - def _calc_sigma_doppler(self) -> None: - # sigma = keff Deltav, keff = 8.7mum^-1, Deltav = sqrt(kB T / m) - self._change_attribute( - "doppler_sigma", KEFF * np.sqrt(KB * self.temperature / MASS) - ) - def _change_attribute(self, attr_name: str, new_value: Any) -> None: object.__setattr__(self, attr_name, new_value) - def _check_eff_noise(self) -> None: - # Check the validity of the distribution of probability - if "eff_noise" in self.noise: - if len(self.eff_noise_opers) != len(self.eff_noise_probs): - raise ValueError( - f"The operators list length({len(self.eff_noise_opers)}) " - "and probabilities list length" - f"({len(self.eff_noise_probs)}) must be equal." - ) - if self.eff_noise_opers == [] or self.eff_noise_probs == []: - raise ValueError( - "The general noise parameters have not been filled." - ) - - for prob in self.eff_noise_probs: - if not isinstance(prob, float): - raise TypeError( - "eff_noise_probs is a list of floats," - f" it must not contain a {type(prob)}." - ) - - prob_distr = np.array(self.eff_noise_probs) - lower_bound = np.any(prob_distr < 0.0) - upper_bound = np.any(prob_distr > 1.0) - sum_p = not np.isclose(sum(prob_distr), 1.0) - - if sum_p or lower_bound or upper_bound: - raise ValueError( - "The distribution given is not a probability distribution." - ) - # Check the validity of operators - for operator in self.eff_noise_opers: - # type checking - - if type(operator) != qutip.qobj.Qobj: - raise TypeError(f"{operator} is not a Qobj.") - if operator.type != "oper": - raise TypeError( - "Operators are supposed to be of type oper." - ) - if operator.shape != (2, 2): - raise NotImplementedError( - "Operator's shape must be (2,2) " - f"not {operator.shape}." - ) - # Identity position - identity = qutip.qeye(2) - if self.eff_noise_opers[0] != identity: - raise NotImplementedError( - "You must put the identity matrix at the " - "beginning of the operator list." - ) - # Completeness relation checking - sum_op = qutip.Qobj(shape=(2, 2)) - length = len(self.eff_noise_probs) - for i in range(length): - sum_op += ( - self.eff_noise_probs[i] - * self.eff_noise_opers[i] - * self.eff_noise_opers[i].dag() - ) - - if sum_op != identity: - raise ValueError( - "The completeness relation is not verified." - f" Ended up with {sum_op} instead of {identity}." + def _check_eff_noise_opers_type(self) -> None: + # Check the validity of operators + for operator in self.eff_noise_opers: + # type checking + if not isinstance(operator, qutip.Qobj): + raise TypeError(f"{operator} is not a Qobj.") + if operator.type != "oper": + raise TypeError( + "Operators are supposed to be of Qutip type 'oper'." ) @property diff --git a/pulser-simulation/pulser_simulation/simresults.py b/pulser-simulation/pulser_simulation/simresults.py index 7961486ee..387916554 100644 --- a/pulser-simulation/pulser_simulation/simresults.py +++ b/pulser-simulation/pulser_simulation/simresults.py @@ -20,7 +20,7 @@ from abc import ABC, abstractmethod from collections import Counter from functools import lru_cache -from typing import Mapping, Optional, Tuple, TypeVar, Union, cast, overload +from typing import Mapping, Optional, Tuple, Union, cast import matplotlib.pyplot as plt import numpy as np @@ -28,13 +28,11 @@ from numpy.typing import ArrayLike from qutip.piqs import isdiagonal -from pulser.result import Result, SampledResult +from pulser.result import Results, ResultType, SampledResult from pulser_simulation.qutip_result import QutipResult -ResultType = TypeVar("ResultType", bound=Result) - -class SimulationResults(ABC, typing.Sequence[ResultType]): +class SimulationResults(ABC, Results[ResultType]): """Results of a simulation run of a pulse sequence. Parent class for NoisyResults and CoherentResults. @@ -66,7 +64,6 @@ def __init__( ) self._basis_name = basis_name self._sim_times = sim_times - self._results: list[ResultType] @property @abstractmethod @@ -226,24 +223,6 @@ def _meas_projector(self, state_n: int) -> qutip.Qobj: # 0 = |g or d> = |1>; 1 = |r or u> = |0> return qutip.basis(2, 1 - state_n).proj() - @overload - def __getitem__(self, key: int) -> ResultType: - pass - - @overload - def __getitem__(self, key: slice) -> list[ResultType]: - pass - - def __getitem__(self, key: int | slice) -> ResultType | list[ResultType]: - return self._results[key] - - def __len__(self) -> int: - return len(self._results) - - def __iter__(self) -> collections.abc.Iterator[ResultType]: - for res in self._results: - yield res - class NoisyResults(SimulationResults): """Results of a noisy simulation run of a pulse sequence. @@ -259,7 +238,7 @@ class NoisyResults(SimulationResults): def __init__( self, - run_output: list[SampledResult], + run_output: typing.Sequence[SampledResult], size: int, basis_name: str, sim_times: np.ndarray, @@ -292,7 +271,7 @@ def __init__( basis_name_ = "digital" if basis_name == "all" else basis_name super().__init__(size, basis_name_, sim_times) self.n_measures = n_measures - self._results: list[SampledResult] = run_output + self._results = tuple(run_output) @property def states(self) -> list[qutip.Qobj]: @@ -381,7 +360,7 @@ class CoherentResults(SimulationResults): def __init__( self, - run_output: list[QutipResult], + run_output: typing.Sequence[QutipResult], size: int, basis_name: str, sim_times: np.ndarray, @@ -417,7 +396,7 @@ def __init__( "`meas_basis` and `basis_name` must have the same value." ) self._meas_basis = meas_basis - self._results = run_output + self._results = tuple(run_output) if meas_errors is not None: if set(meas_errors) != {"epsilon", "epsilon_prime"}: raise ValueError( diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index a2ef1cc1e..ef62689ff 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -347,10 +347,6 @@ def add_config(self, config: SimConfig) -> None: diff_noise_set = new_noise_set - old_noise_set # Create temporary param_dict to add noise parameters: param_dict: dict[str, Any] = asdict(self._config) - # remove redundant `spam_dict`: - del param_dict["spam_dict"] - # `doppler_sigma` will be recalculated from temperature if needed: - del param_dict["doppler_sigma"] # Begin populating with added noise parameters: param_dict["noise"] = tuple(new_noise_set) if "SPAM" in diff_noise_set: @@ -460,7 +456,7 @@ def set_evaluation_times( elif isinstance(value, float): if value > 1 or value <= 0: raise ValueError( - "evaluation_times float must be between 0 " "and 1." + "evaluation_times float must be between 0 and 1." ) indices = np.linspace( 0, @@ -878,7 +874,7 @@ def get_hamiltonian(self, time: float) -> qutip.Qobj: # Run Simulation Evolution using Qutip def run( self, - progress_bar: Optional[bool] = False, + progress_bar: bool = False, **options: Any, ) -> SimulationResults: """Simulates the sequence using QuTiP's solvers. diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 000000000..aa4fab5bb --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,271 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import typing + +import numpy as np +import pytest + +import pulser +from pulser.backend.abc import Backend +from pulser.backend.config import EmulatorConfig +from pulser.backend.noise_model import NoiseModel +from pulser.backend.qpu import QPUBackend +from pulser.backend.remote import ( + RemoteConnection, + RemoteResults, + RemoteResultsError, + SubmissionStatus, +) +from pulser.devices import Chadoq2, MockDevice +from pulser.result import Result, SampledResult + + +@pytest.fixture +def sequence() -> pulser.Sequence: + reg = pulser.Register.square(2, spacing=5, prefix="q") + seq = pulser.Sequence(reg, MockDevice) + seq.declare_channel("rydberg_global", "rydberg_global") + seq.add(pulser.Pulse.ConstantPulse(1000, 1, -1, 0), "rydberg_global") + return seq + + +def test_abc_backend(sequence): + with pytest.raises( + TypeError, match="Can't instantiate abstract class Backend" + ): + Backend(sequence) + + class ConcreteBackend(Backend): + def run(self): + pass + + with pytest.raises( + TypeError, match="'sequence' should be a `Sequence` instance" + ): + ConcreteBackend(sequence.to_abstract_repr()) + + +@pytest.mark.parametrize( + "param, value, msg", + [ + ("sampling_rate", 0, "must be greater than 0"), + ("evaluation_times", "full", "one of the following"), + ("evaluation_times", 1.001, "less than or equal to 1"), + ("evaluation_times", [-1e9, 1], "must not contain negative values"), + ("initial_state", "all_ground", "must be 'all-ground'"), + ], +) +def test_emulator_config_value_errors(param, value, msg): + with pytest.raises(ValueError, match=msg): + EmulatorConfig(**{param: value}) + + +@pytest.mark.parametrize( + "param, msg", + [ + ("evaluation_times", "not a valid type for 'evaluation_times'"), + ("initial_state", "not a valid type for 'initial_state'"), + ("noise_model", "must be a NoiseModel instance"), + ], +) +def test_emulator_config_type_errors(param, msg): + with pytest.raises(TypeError, match=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, 1.0001]) + @pytest.mark.parametrize( + "param", + [ + "state_prep_error", + "p_false_pos", + "p_false_neg", + "dephasing_prob", + "depolarizing_prob", + "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.mark.parametrize( + "noise_sample,", + [ + ("dephasing", "depolarizing"), + ("eff_noise", "depolarizing"), + ("eff_noise", "dephasing"), + ("depolarizing", "eff_noise", "dephasing"), + ], + ) + def test_eff_noise_init(self, noise_sample): + with pytest.raises( + NotImplementedError, + match="Depolarizing, dephasing and effective noise channels", + ): + NoiseModel(noise_types=noise_sample) + + @pytest.fixture + def matrices(self): + matrices = {} + matrices["I"] = np.eye(2) + matrices["X"] = np.ones((2, 2)) - np.eye(2) + 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 + + @pytest.mark.parametrize( + "prob_distr", + [ + [-1.0, 0.5], + [0.5, 2.0], + [0.3, 0.2], + ], + ) + def test_eff_noise_probs(self, prob_distr, matrices): + with pytest.raises( + ValueError, match="is not a probability distribution." + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["I"], matrices["X"]], + eff_noise_probs=prob_distr, + ) + + def test_eff_noise_opers(self, matrices): + with pytest.raises(ValueError, match="The operators list length"): + NoiseModel(noise_types=("eff_noise",), eff_noise_probs=[1.0]) + with pytest.raises( + TypeError, match="eff_noise_probs is a list of floats" + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_probs=["0.1"], + eff_noise_opers=[np.eye(2)], + ) + with pytest.raises( + ValueError, + match="The general noise parameters have not been filled.", + ): + NoiseModel(noise_types=("eff_noise",)) + with pytest.raises(TypeError, match="is not a Numpy array."): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[2.0], + eff_noise_probs=[1.0], + ) + with pytest.raises(NotImplementedError, match="Operator's shape"): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["I3"]], + eff_noise_probs=[1.0], + ) + with pytest.raises( + NotImplementedError, match="You must put the identity matrix" + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["X"], matrices["I"]], + eff_noise_probs=[0.5, 0.5], + ) + with pytest.raises( + ValueError, match="The completeness relation is not" + ): + NoiseModel( + noise_types=("eff_noise",), + eff_noise_opers=[matrices["I"], matrices["Zh"]], + eff_noise_probs=[0.5, 0.5], + ) + + +class _MockConnection(RemoteConnection): + def __init__(self): + self._status_calls = 0 + + def submit(self, sequence, **kwargs) -> RemoteResults: + return RemoteResults("abcd", self) + + def _fetch_result(self, submission_id: str) -> typing.Sequence[Result]: + return ( + SampledResult( + ("q0", "q1"), + meas_basis="ground-rydberg", + bitstring_counts={"00": 100}, + ), + ) + + def _get_submission_status(self, submission_id: str) -> SubmissionStatus: + self._status_calls += 1 + if self._status_calls == 1: + return SubmissionStatus.RUNNING + return SubmissionStatus.DONE + + +def test_qpu_backend(sequence): + connection = _MockConnection() + + with pytest.raises( + TypeError, match="must be a real device, instance of 'Device'" + ): + QPUBackend(sequence, connection) + + seq = sequence.switch_device(Chadoq2) + qpu_backend = QPUBackend(seq, connection) + with pytest.raises(ValueError, match="'job_params' must be specified"): + qpu_backend.run() + with pytest.raises( + ValueError, + match="All elements of 'job_params' must specify 'runs'", + ): + qpu_backend.run(job_params=[{"n_runs": 10}, {"runs": 1}]) + + remote_results = qpu_backend.run(job_params=[{"runs": 10}]) + + with pytest.raises(AttributeError, match="no attribute 'result'"): + # Cover the custom '__getattr__' default behavior + remote_results.result + + with pytest.raises( + RemoteResultsError, + match="The results are not available. The submission's status is" + " SubmissionStatus.RUNNING", + ): + remote_results.results + + results = remote_results.results + assert results[0].sampling_dist == {"00": 1.0} diff --git a/tests/test_pasqal.py b/tests/test_pasqal.py index c0cff70d3..d80e59017 100644 --- a/tests/test_pasqal.py +++ b/tests/test_pasqal.py @@ -16,16 +16,25 @@ import dataclasses from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from pasqal_cloud.device.configuration import EmuFreeConfig, EmuTNConfig import pulser import pulser_pasqal +from pulser.backend.config import EmulatorConfig +from pulser.backend.remote import ( + RemoteConnection, + RemoteResults, + SubmissionStatus, +) from pulser.devices import Chadoq2 from pulser.register import Register +from pulser.result import SampledResult from pulser.sequence import Sequence from pulser_pasqal import BaseConfig, EmulatorType, Endpoints, PasqalCloud +from pulser_pasqal.backends import EmuFreeBackend, EmuTNBackend from pulser_pasqal.job_parameters import JobParameters, JobVariables root = Path(__file__).parent.parent @@ -41,8 +50,45 @@ class CloudFixture: mock_cloud_sdk: Any +test_device = Chadoq2 +virtual_device = test_device.to_virtual() + + +@pytest.fixture +def seq(): + reg = Register.square(2, spacing=10, prefix="q") + return Sequence(reg, test_device) + + @pytest.fixture -def fixt(): +def mock_job(): + @dataclasses.dataclass + class MockJob: + variables = {"t": 100, "qubits": {"q0": 1, "q1": 2, "q2": 4, "q3": 3}} + result = {"00": 5, "11": 5} + + return MockJob() + + +@pytest.fixture +def mock_batch(mock_job, seq): + with pytest.warns(UserWarning): + seq_ = seq.build() + seq_.declare_channel("rydberg_global", "rydberg_global") + seq_.measure() + + @dataclasses.dataclass + class MockBatch: + id = "abcd" + status = "DONE" + jobs = {"job1": mock_job} + sequence_builder = seq_.to_abstract_repr() + + return MockBatch() + + +@pytest.fixture +def fixt(monkeypatch, mock_batch): with patch("pasqal_cloud.SDK", autospec=True) as mock_cloud_sdk_class: pasqal_cloud_kwargs = dict( username="abc", @@ -54,12 +100,24 @@ def fixt(): pasqal_cloud = PasqalCloud(**pasqal_cloud_kwargs) + with pytest.raises(NotImplementedError): + pasqal_cloud.fetch_available_devices() + + monkeypatch.setattr( + PasqalCloud, + "fetch_available_devices", + lambda _: {test_device.name: test_device}, + ) + mock_cloud_sdk_class.assert_called_once_with(**pasqal_cloud_kwargs) mock_cloud_sdk = mock_cloud_sdk_class.return_value mock_cloud_sdk_class.reset_mock() + mock_cloud_sdk.create_batch = MagicMock(return_value=mock_batch) + mock_cloud_sdk.get_batch = MagicMock(return_value=mock_batch) + yield CloudFixture( pasqal_cloud=pasqal_cloud, mock_cloud_sdk=mock_cloud_sdk ) @@ -67,8 +125,179 @@ def fixt(): mock_cloud_sdk_class.assert_not_called() -test_device = Chadoq2 -virtual_device = test_device.to_virtual() +@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): + with pytest.raises( + ValueError, + match="The measurement basis can't be implicitly determined for a " + "sequence not addressing a single basis", + ): + fixt.pasqal_cloud.submit(seq) + + 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 + + if not emulator: + seq2 = seq.switch_device(virtual_device) + with pytest.raises( + ValueError, + match="The device used in the sequence does not match any " + "of the devices currently available through the remote " + "connection.", + ): + fixt.pasqal_cloud.submit(seq2, job_params=[dict(runs=10)]) + + if parametrized: + with pytest.raises( + TypeError, match="Did not receive values for variables" + ): + fixt.pasqal_cloud.submit(seq, job_params=[{"runs": 100}]) + + assert not seq.is_measured() + config = EmulatorConfig( + sampling_rate=0.5, backend_options=dict(with_noise=False) + ) + + if emulator is None: + sdk_config = None + elif emulator == EmulatorType.EMU_FREE: + sdk_config = EmuFreeConfig(with_noise=False) + else: + sdk_config = EmuTNConfig(dt=2, extra_config={"with_noise": False}) + + assert ( + fixt.pasqal_cloud._convert_configuration(config, emulator) + == sdk_config + ) + + job_params = [{"runs": 10, "variables": {"t": 100}}] + remote_results = fixt.pasqal_cloud.submit( + seq, + job_params=job_params, + emulator=emulator, + config=config, + ) + assert not seq.is_measured() + seq.measure(basis="ground-rydberg") + + fixt.mock_cloud_sdk.create_batch.assert_called_once_with( + **dict( + serialized_sequence=seq.to_abstract_repr(), + jobs=job_params, + emulator=emulator, + configuration=sdk_config, + wait=False, + fetch_results=False, + ) + ) + + assert isinstance(remote_results, RemoteResults) + assert remote_results.get_status() == SubmissionStatus.DONE + fixt.mock_cloud_sdk.get_batch.assert_called_once_with( + id=remote_results._submission_id, fetch_results=False + ) + + fixt.mock_cloud_sdk.get_batch.reset_mock() + results = remote_results.results + fixt.mock_cloud_sdk.get_batch.assert_called_with( + id=remote_results._submission_id, fetch_results=True + ) + assert results == ( + SampledResult( + atom_order=("q0", "q1", "q2", "q3"), + meas_basis="ground-rydberg", + bitstring_counts=mock_job.result, + ), + ) + assert hasattr(remote_results, "_results") + + +@pytest.mark.parametrize("emu_cls", [EmuTNBackend, EmuFreeBackend]) +def test_emulators_init(fixt, seq, emu_cls, monkeypatch): + with pytest.raises( + TypeError, + match="'connection' must be a valid RemoteConnection instance.", + ): + emu_cls(seq, "connection") + with pytest.raises( + TypeError, match="'config' must be of type 'EmulatorConfig'" + ): + emu_cls(seq, fixt.pasqal_cloud, {"with_noise": True}) + + with pytest.raises( + NotImplementedError, + match="'EmulatorConfig.with_modulation' is not configurable in this " + "backend. It should not be changed from its default value of 'False'.", + ): + emu_cls( + seq, + fixt.pasqal_cloud, + EmulatorConfig( + sampling_rate=0.25, + evaluation_times="Final", + with_modulation=True, + ), + ) + + monkeypatch.setattr(RemoteConnection, "__abstractmethods__", set()) + with pytest.raises( + TypeError, + match="connection to the remote backend must be done" + " through a 'PasqalCloud' instance.", + ): + emu_cls(seq, RemoteConnection()) + + +@pytest.mark.parametrize("parametrized", [True, False]) +@pytest.mark.parametrize("emu_cls", [EmuTNBackend, EmuFreeBackend]) +def test_emulators_run(fixt, seq, emu_cls, parametrized: bool): + 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) + + bad_kwargs = {} if parametrized else {"job_params": [{"runs": 100}]} + err_msg = ( + "'job_params' must be provided" + if parametrized + else "'job_params' cannot be provided" + ) + with pytest.raises(ValueError, match=err_msg): + emu.run(**bad_kwargs) + + good_kwargs = ( + {"job_params": [{"variables": {"t": 100}}]} if parametrized else {} + ) + remote_results = emu.run(**good_kwargs) + assert isinstance(remote_results, RemoteResults) + + sdk_config: EmuTNConfig | EmuFreeConfig + if isinstance(emu, EmuTNBackend): + emulator_type = EmulatorType.EMU_TN + sdk_config = EmuTNConfig() + else: + emulator_type = EmulatorType.EMU_FREE + sdk_config = EmuFreeConfig() + 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(), + jobs=good_kwargs.get("job_params", []), + emulator=emulator_type, + configuration=sdk_config, + wait=False, + fetch_results=False, + ) + + +# Deprecated def check_pasqal_cloud(fixt, seq, emulator, expected_seq_representation): @@ -95,6 +324,7 @@ def check_pasqal_cloud(fixt, seq, emulator, expected_seq_representation): seq, **create_batch_kwargs, ) + assert pulser_pasqal.__version__ < "0.15" fixt.mock_cloud_sdk.create_batch.assert_called_once_with( serialized_sequence=expected_seq_representation, @@ -105,8 +335,9 @@ def check_pasqal_cloud(fixt, seq, emulator, expected_seq_representation): id="uuid", fetch_results=True, ) - - fixt.pasqal_cloud.get_batch(**get_batch_kwargs) + with pytest.deprecated_call(): + fixt.pasqal_cloud.get_batch(**get_batch_kwargs) + assert pulser_pasqal.__version__ < "0.15" fixt.mock_cloud_sdk.get_batch.assert_called_once_with(**get_batch_kwargs) @@ -152,7 +383,9 @@ def test_virtual_device_on_qpu_error(fixt): device = Chadoq2.to_virtual() seq = Sequence(reg, device) - with pytest.raises(TypeError, match="must be a real device"): + with pytest.deprecated_call(), pytest.raises( + TypeError, match="must be a real device" + ): fixt.pasqal_cloud.create_batch( seq, jobs=[JobParameters(runs=10, variables=JobVariables(a=[3, 5]))], diff --git a/tests/test_qutip_backend.py b/tests/test_qutip_backend.py new file mode 100644 index 000000000..c45086f6f --- /dev/null +++ b/tests/test_qutip_backend.py @@ -0,0 +1,55 @@ +# Copyright 2023 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import numpy as np +import pytest +import qutip + +import pulser +from pulser.devices import MockDevice +from pulser.waveforms import BlackmanWaveform +from pulser_simulation import SimConfig +from pulser_simulation.qutip_backend import QutipBackend +from pulser_simulation.qutip_result import QutipResult +from pulser_simulation.simresults import CoherentResults + + +@pytest.fixture +def sequence(): + reg = pulser.Register({"q0": (0, 0)}) + seq = pulser.Sequence(reg, MockDevice) + seq.declare_channel("raman_local", "raman_local", initial_target="q0") + seq.add( + pulser.Pulse.ConstantDetuning(BlackmanWaveform(1000, np.pi), 0, 0), + "raman_local", + ) + return seq + + +def test_qutip_backend(sequence): + sim_config = SimConfig() + with pytest.raises(TypeError, match="must be of type 'EmulatorConfig'"): + QutipBackend(sequence, sim_config) + + qutip_backend = QutipBackend(sequence) + results = qutip_backend.run() + assert isinstance(results, CoherentResults) + assert results[0].get_state() == qutip.basis(2, 0) + + final_result = results[-1] + assert isinstance(final_result, QutipResult) + final_state = final_result.get_state() + assert final_state == results.get_final_state() + np.testing.assert_allclose(final_state.full(), [[0], [1]], atol=1e-5) diff --git a/tests/test_sequence.py b/tests/test_sequence.py index d3eb19540..837d3d44b 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -65,8 +65,12 @@ def test_init(reg, device): def test_channel_declaration(reg, device): seq = Sequence(reg, device) available_channels = set(seq.available_channels) + assert seq.get_addressed_bases() == () + seq.declare_channel("ch0", "rydberg_global") + assert seq.get_addressed_bases() == ("ground-rydberg",) seq.declare_channel("ch1", "raman_local") + assert seq.get_addressed_bases() == ("ground-rydberg", "digital") with pytest.raises(ValueError, match="No channel"): seq.declare_channel("ch2", "raman") with pytest.raises(ValueError, match="not available"): @@ -730,14 +734,29 @@ def test_align(reg, device): seq.align("ch1") -def test_measure(reg, device): +@pytest.mark.parametrize("parametrized", [True, False]) +def test_measure(reg, parametrized): pulse = Pulse.ConstantPulse(500, 2, -10, 0, post_phase_shift=np.pi) seq = Sequence(reg, MockDevice) seq.declare_channel("ch0", "rydberg_global") + t = seq.declare_variable("t", dtype=int) + seq.delay(t if parametrized else 100, "ch0") + assert seq.is_parametrized() == parametrized + assert "XY" in MockDevice.supported_bases with pytest.raises(ValueError, match="not supported"): seq.measure(basis="XY") - seq.measure() + with pytest.raises( + RuntimeError, match="The sequence has not been measured" + ): + seq.get_measurement_basis() + with pytest.warns( + UserWarning, + match="'digital' is not being addressed by " + "any channel in the sequence", + ): + seq.measure(basis="digital") + assert seq.get_measurement_basis() == "digital" with pytest.raises( RuntimeError, match="sequence has been measured, no further changes are allowed.", diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index c70201799..14971fd6b 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -390,7 +390,7 @@ def seqs(seq_rydberg) -> list[pulser.Sequence]: seq = pulser.Sequence(reg, MockDevice) seq.declare_channel("ch0", "raman_global") seq.add(pulse, "ch0") - seq.measure() + seq.measure(basis="digital") seqs.append(deepcopy(seq)) seqs.append(seq_rydberg) diff --git a/tests/test_simconfig.py b/tests/test_simconfig.py index 37393c437..6662ceabe 100644 --- a/tests/test_simconfig.py +++ b/tests/test_simconfig.py @@ -15,6 +15,7 @@ import pytest from qutip import Qobj, qeye, sigmax, sigmaz +from pulser.backend.noise_model import NoiseModel from pulser_simulation import SimConfig @@ -29,7 +30,7 @@ def matrices(): return pauli -def test_init(matrices): +def test_init(): config = SimConfig( noise=( "SPAM", @@ -52,7 +53,7 @@ def test_init(matrices): assert "depolarizing" in str_config config = SimConfig( noise="eff_noise", - eff_noise_opers=[matrices["I"], matrices["X"]], + eff_noise_opers=[qeye(2), sigmax()], eff_noise_probs=[0.3, 0.7], ) str_config = config.__str__(True) @@ -60,58 +61,26 @@ def test_init(matrices): "Effective noise distribution" in str_config and "Effective noise operators" in str_config ) - with pytest.raises(ValueError, match="is not a valid noise type."): - SimConfig(noise="bad_noise") - with pytest.raises(ValueError, match="Temperature field"): - SimConfig(temperature=-1.0) + + with pytest.raises(TypeError, match="'temperature' must be a float"): + SimConfig(temperature="0.0") with pytest.raises(ValueError, match="SPAM parameter"): SimConfig(eta=-1.0) with pytest.raises( - ValueError, match="The standard deviation in amplitude" + ValueError, match="'amp_sigma' must be greater than or equal to zero" ): SimConfig(amp_sigma=-0.001) -@pytest.mark.parametrize( - "noise_sample,", - [ - ("dephasing", "depolarizing"), - ("eff_noise", "depolarizing"), - ("eff_noise", "dephasing"), - ("depolarizing", "eff_noise", "dephasing"), - ], -) -def test_eff_noise_init(noise_sample): - with pytest.raises( - NotImplementedError, - match="Depolarizing, dephasing and eff_noise channels", - ): - SimConfig(noise=noise_sample) - - -@pytest.mark.parametrize( - "prob_distr", - [ - [-1.0, 0.5], - [0.5, 2.0], - [0.3, 0.2], - ], -) -def test_eff_noise_probs(prob_distr): - with pytest.raises(ValueError, match="is not a probability distribution."): - SimConfig( - noise=("eff_noise"), - eff_noise_opers=[qeye(2), sigmax()], - eff_noise_probs=prob_distr, - ) - - def test_eff_noise_opers(matrices): + # Some of these checks are repeated in the NoiseModel UTs with pytest.raises(ValueError, match="The operators list length"): SimConfig(noise=("eff_noise"), eff_noise_probs=[1.0]) with pytest.raises(TypeError, match="eff_noise_probs is a list of floats"): SimConfig( - noise=("eff_noise"), eff_noise_probs=[""], eff_noise_opers=[""] + noise=("eff_noise"), + eff_noise_probs=["0.1"], + eff_noise_opers=[qeye(2)], ) with pytest.raises( ValueError, match="The general noise parameters have not been filled." @@ -121,7 +90,7 @@ def test_eff_noise_opers(matrices): SimConfig( noise=("eff_noise"), eff_noise_opers=[2.0], eff_noise_probs=[1.0] ) - with pytest.raises(TypeError, match="to be of type oper."): + with pytest.raises(TypeError, match="to be of Qutip type 'oper'."): SimConfig( noise=("eff_noise"), eff_noise_opers=[matrices["ket"]], @@ -147,3 +116,15 @@ def test_eff_noise_opers(matrices): eff_noise_opers=[matrices["I"], matrices["Zh"]], eff_noise_probs=[0.5, 0.5], ) + + +def test_from_noise_model(): + noise_model = NoiseModel( + noise_types=("SPAM",), + p_false_neg=0.4, + p_false_pos=0.1, + state_prep_error=0.05, + ) + assert SimConfig.from_noise_model(noise_model) == SimConfig( + noise="SPAM", epsilon=0.1, epsilon_prime=0.4, eta=0.05 + ) From 10fef9c100dbdbd7f0fb751b5d35832c06de0194 Mon Sep 17 00:00:00 2001 From: Davide Gessa Date: Tue, 30 May 2023 12:21:22 +0200 Subject: [PATCH 03/19] Add an error when a Channel is called with an eom_config but without a modulation bandwidth (#526) * Add an error when a Channel is called with an eom_config but without a modulation bandwidth * Fix lint and test * fix channels test order --- pulser-core/pulser/channels/base_channel.py | 6 ++++++ pulser-core/pulser/channels/channels.py | 18 +++++++----------- tests/test_channels.py | 12 ++++++------ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pulser-core/pulser/channels/base_channel.py b/pulser-core/pulser/channels/base_channel.py index 5b9ba3a22..a8344ca07 100644 --- a/pulser-core/pulser/channels/base_channel.py +++ b/pulser-core/pulser/channels/base_channel.py @@ -177,6 +177,12 @@ def __post_init__(self) -> None: f"'mod_bandwidth' must be lower than {MODBW_TO_TR*1e3} MHz" ) + if self.eom_config is not None and self.mod_bandwidth is None: + raise ValueError( + "'eom_config' can't be defined in a Channel without a " + "modulation bandwidth." + ) + @property def rise_time(self) -> int: """The rise time (in ns). diff --git a/pulser-core/pulser/channels/channels.py b/pulser-core/pulser/channels/channels.py index 51cdaaca3..390286b71 100644 --- a/pulser-core/pulser/channels/channels.py +++ b/pulser-core/pulser/channels/channels.py @@ -48,17 +48,13 @@ class Rydberg(Channel): def __post_init__(self) -> None: super().__post_init__() - if self.eom_config is not None: - if not isinstance(self.eom_config, RydbergEOM): - raise TypeError( - "When defined, 'eom_config' must be a valid 'RydbergEOM'" - f" instance, not {type(self.eom_config)}." - ) - if self.mod_bandwidth is None: - raise ValueError( - "'eom_config' can't be defined in a Channel without a " - "modulation bandwidth." - ) + if self.eom_config is not None and not isinstance( + self.eom_config, RydbergEOM + ): + raise TypeError( + "When defined, 'eom_config' must be a valid 'RydbergEOM'" + f" instance, not {type(self.eom_config)}." + ) @property def basis(self) -> Literal["ground-rydberg"]: diff --git a/tests/test_channels.py b/tests/test_channels.py index 1b34fbe01..1a47c3a8a 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -189,12 +189,6 @@ def test_repr(): def test_eom_channel(): - with pytest.raises( - TypeError, - match="When defined, 'eom_config' must be a valid 'RydbergEOM'", - ): - Rydberg.Global(None, None, eom_config=BaseEOM(50)) - with pytest.raises( ValueError, match="'eom_config' can't be defined in a Channel without a" @@ -202,6 +196,12 @@ def test_eom_channel(): ): Rydberg.Global(None, None, eom_config=_eom_config) + with pytest.raises( + TypeError, + match="When defined, 'eom_config' must be a valid 'RydbergEOM'", + ): + Rydberg.Global(None, None, mod_bandwidth=3, eom_config=BaseEOM(50)) + assert not Rydberg.Global(None, None).supports_eom() assert Rydberg.Global( None, None, mod_bandwidth=3, eom_config=_eom_config From 742c5343ff49bdb865d992957f2f706a47da51f8 Mon Sep 17 00:00:00 2001 From: Davide Gessa Date: Fri, 2 Jun 2023 09:21:13 +0200 Subject: [PATCH 04/19] Fix SPAM errors introducing overhead (#529) * Handle eps_p = eps = 0 case * Improve SPAM simulation performance * Improve sample_state performance using tuple indexing * Improvements of sample_state() performance * Join meas_errors checks in simresults sample_state * Update comments on simresults sample_state --- .../pulser_simulation/simresults.py | 52 +++++++++++-------- tests/test_simulation.py | 20 +++++++ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/pulser-simulation/pulser_simulation/simresults.py b/pulser-simulation/pulser_simulation/simresults.py index 387916554..8e954c543 100644 --- a/pulser-simulation/pulser_simulation/simresults.py +++ b/pulser-simulation/pulser_simulation/simresults.py @@ -521,28 +521,34 @@ def sample_state( quantum states at time t. """ sampled_state = super().sample_state(t, n_samples, t_tol) - if self._meas_errors is None: + if self._meas_errors is None or ( + self._meas_errors["epsilon"] == 0.0 + and self._meas_errors["epsilon_prime"] == 0 + ): return sampled_state - detected_sample_dict: Counter = Counter() - for shot, n_detects in sampled_state.items(): - eps = self._meas_errors["epsilon"] - eps_p = self._meas_errors["epsilon_prime"] - # Shot as an array of 1s and 0s - shot_arr = np.array(list(shot), dtype=int) - # Probability of flipping each bit - flip_probs = np.array([eps_p if x == "1" else eps for x in shot]) - # 1 if it flips, 0 if it stays the same - flips = ( - np.random.uniform(size=(n_detects, len(flip_probs))) - < flip_probs - ).astype(int) - # XOR betwen the original array and the flips - # Gives an array of n_detects individual shots - new_shots = shot_arr ^ flips - # Count all the new_shots - detected_sample_dict += Counter( - "".join(map(str, measured)) for measured in new_shots - ) - - return detected_sample_dict + eps = self._meas_errors["epsilon"] + eps_p = self._meas_errors["epsilon_prime"] + shots = list(sampled_state.keys()) + n_detects_list = list(sampled_state.values()) + + # Convert shots to a 2D array + shot_arr = np.array([list(shot) for shot in shots], dtype=int) + # Compute flip probabilities + flip_probs = np.where(shot_arr == 1, eps_p, eps) + # Repeat flip_probs based on n_detects_list + flip_probs_repeated = np.repeat(flip_probs, n_detects_list, axis=0) + # Generate random matrix of shape (sum(n_detects_list), len(shot)) + random_matrix = np.random.uniform( + size=(np.sum(n_detects_list), len(shot_arr[0])) + ) + # Compare random matrix with flip probabilities + flips = random_matrix < flip_probs_repeated + # Perform XOR between original array and flips + new_shots = shot_arr.repeat(n_detects_list, axis=0) ^ flips + # Count all the new_shots + # We are not converting to str before because tuple indexing is faster + detected_sample_dict: Counter = Counter(map(tuple, new_shots)) + return Counter( + {"".join(map(str, k)): v for k, v in detected_sample_dict.items()} + ) diff --git a/tests/test_simulation.py b/tests/test_simulation.py index f08950943..3959376a3 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -643,6 +643,26 @@ def test_noise(seq, matrices): assert np.all(sim2.samples["Local"][basis][t][qty] == 0.0) +def test_noise_with_zero_epsilons(seq, matrices): + np.random.seed(3) + sim = Simulation(seq, sampling_rate=0.01) + + sim2 = Simulation( + seq, + sampling_rate=0.01, + config=SimConfig( + noise=("SPAM"), eta=0.0, epsilon=0.0, epsilon_prime=0.0 + ), + ) + assert sim2.config.spam_dict == { + "eta": 0, + "epsilon": 0.0, + "epsilon_prime": 0.0, + } + + assert sim.run().sample_final_state() == sim2.run().sample_final_state() + + def test_dephasing(): np.random.seed(123) reg = Register.from_coordinates([(0, 0)], prefix="q") From 23fc35981b1dafb4dd0bae0c481f1a308e675946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Mon, 5 Jun 2023 16:49:25 +0200 Subject: [PATCH 05/19] Fixes for emulation of empty sequences (#531) * Fix for empty sequences on QutipEmulator * Add UTs --- pulser-core/pulser/sampler/samples.py | 5 ++++ pulser-core/pulser/sequence/_schedule.py | 5 +++- .../pulser_simulation/simulation.py | 14 +++++----- tests/test_sequence_sampler.py | 26 +++++++++++++++++++ tests/test_simulation.py | 10 ++++++- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 49072391f..8a8a05fc2 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -91,6 +91,7 @@ class ChannelSamples: phase: np.ndarray slots: list[_TargetSlot] = field(default_factory=list) eom_blocks: list[_EOMSettings] = field(default_factory=list) + initial_targets: set[QubitId] = field(default_factory=set) def __post_init__(self) -> None: assert len(self.amp) == len(self.det) == len(self.phase) @@ -373,6 +374,10 @@ def to_nested_dict(self, all_local: bool = False) -> dict: d[_LOCAL][basis][t][_DET][:start_t] += cs.det[:start_t] d[_LOCAL][basis][t][_PHASE][:start_t] += cs.phase[:start_t] else: + if not cs.slots: + # Fill the defaultdict entries to not return an empty dict + for t in cs.initial_targets: + d[_LOCAL][basis][t] for s in cs.slots: for t in s.targets: ti = s.ti diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index 775632f05..010e1dbe2 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -138,6 +138,7 @@ def get_samples( dt = self.get_duration() amp, det, phase = np.zeros(dt), np.zeros(dt), np.zeros(dt) slots: list[_TargetSlot] = [] + initial_targets = self.slots[0].targets if self.slots else set() for ind, s in enumerate(channel_slots): pulse = cast(Pulse, s.type) @@ -181,7 +182,9 @@ def get_samples( # the same, so the last phase is automatically kept till the end phase[t_start:] = pulse.phase - return ChannelSamples(amp, det, phase, slots, self.eom_blocks) + return ChannelSamples( + amp, det, phase, slots, self.eom_blocks, initial_targets + ) @overload def __getitem__(self, key: int) -> _TimeSlot: diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index ef62689ff..bd3a1a67c 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -894,10 +894,8 @@ def run( .. _docs: https://bit.ly/3il9A2u """ - if "max_step" in options.keys(): - solv_ops = qutip.Options(**options) - else: - min_pulse_duration = min( + if "max_step" not in options: + pulse_durations = [ slot.tf - slot.ti for ch_sample in self.samples_obj.samples_list for slot in ch_sample.slots @@ -905,9 +903,11 @@ def run( np.all(np.isclose(ch_sample.amp[slot.ti : slot.tf], 0)) and np.all(np.isclose(ch_sample.det[slot.ti : slot.tf], 0)) ) - ) - auto_max_step = 0.5 * (min_pulse_duration / 1000) - solv_ops = qutip.Options(max_step=auto_max_step, **options) + ] + if pulse_durations: + options["max_step"] = 0.5 * min(pulse_durations) / 1000 + + solv_ops = qutip.Options(**options) meas_errors: Optional[Mapping[str, float]] = None if "SPAM" in self.config.noise: diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index 14971fd6b..fb253fd0c 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -74,6 +74,32 @@ def test_init_error(seq_rydberg): sample(seq_rydberg) +@pytest.mark.parametrize("local_only", [True, False]) +def test_delay_only(local_only): + seq_ = pulser.Sequence(pulser.Register({"q0": (0, 0)}), MockDevice) + seq_.declare_channel("ch0", "rydberg_global") + seq_.delay(16, "ch0") + samples = sample(seq_) + assert samples.channel_samples["ch0"].initial_targets == {"q0"} + + qty_dict = { + "amp": np.zeros(16), + "det": np.zeros(16), + "phase": np.zeros(16), + } + if local_only: + expected = { + "Local": {"ground-rydberg": {"q0": qty_dict}}, + "Global": dict(), + } + else: + expected = {"Global": {"ground-rydberg": qty_dict}, "Local": dict()} + + assert_nested_dict_equality( + samples.to_nested_dict(all_local=local_only), expected + ) + + def test_one_pulse_sampling(): """Test the sample function on a one-pulse sequence.""" reg = pulser.Register.square(1, prefix="q") diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 3959376a3..e8d442650 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -328,11 +328,19 @@ def test_empty_sequences(reg): QutipEmulator(sampler.sample(seq), seq.register, seq.device) seq = Sequence(reg, MockDevice) - seq.declare_channel("test", "rydberg_local", "target") + seq.declare_channel("test", "raman_local", "target") seq.declare_channel("test2", "rydberg_global") with pytest.raises(ValueError, match="No instructions given"): Simulation(seq) + seq.delay(100, "test") + emu = QutipEmulator.from_sequence(seq, config=SimConfig(noise="SPAM")) + assert not emu.samples["Global"] + for basis in emu.samples["Local"]: + for q in emu.samples["Local"][basis]: + for qty_values in emu.samples["Local"][basis][q].values(): + np.testing.assert_equal(qty_values, 0) + def test_get_hamiltonian(): simple_reg = Register.from_coordinates([[10, 0], [0, 0]], prefix="atom") From 5191da1b1ef33e1742fa49ebdb43c47a94b3adb9 Mon Sep 17 00:00:00 2001 From: Davide Gessa Date: Mon, 5 Jun 2023 18:24:52 +0200 Subject: [PATCH 06/19] Fix Register drawing out of window (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Register drawing out of window * - use a constrained layout for Register draw - set a minimum size for Register draw * Update draw layout param for seq_drawer and reg_drawer * Add a minimum height for register drawing --------- Co-authored-by: Henrique Silvério --- pulser-core/pulser/register/_reg_drawer.py | 13 ++++++++----- pulser-core/pulser/register/register.py | 1 + pulser-core/pulser/sequence/_seq_drawer.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pulser-core/pulser/register/_reg_drawer.py b/pulser-core/pulser/register/_reg_drawer.py index aff191607..7c6e3ae32 100644 --- a/pulser-core/pulser/register/_reg_drawer.py +++ b/pulser-core/pulser/register/_reg_drawer.py @@ -212,7 +212,7 @@ def _draw_3D( blockade_radius=blockade_radius, draw_half_radius=draw_half_radius, ) - fig.tight_layout(w_pad=6.5) + fig.get_layout_engine().set(w_pad=6.5) for ax, (ix, iy) in zip(axes, combinations(np.arange(3), 2)): RegDrawer._draw_2D( @@ -329,11 +329,12 @@ def _initialize_fig_axes( ) big_side = max(diffs) proportions = diffs / big_side - Ls = proportions * min( - big_side / 4, 10 - ) # Figsize is, at most, (10,10) - fig, axes = plt.subplots(figsize=Ls) + Ls = proportions * max( + min(big_side / 4, 10), 4 + ) # Figsize is, at most, (10,10), and, at least (4,*) or (*,4) + Ls[1] = max(Ls[1], 1.0) # Figsize height is at least 1 + fig, axes = plt.subplots(figsize=Ls, layout="constrained") return (fig, axes) @staticmethod @@ -356,6 +357,7 @@ def _initialize_fig_axes_projection( Ls *= max( min(big_side / 4, 10), 4 ) # Figsize is, at most, (10,10), and, at least (4,*) or (*,4) + Ls[1] = max(Ls[1], 1.0) # Figsize height is at least 1 proportions.append(Ls) fig_height = np.max([Ls[1] for Ls in proportions]) @@ -374,6 +376,7 @@ def _initialize_fig_axes_projection( ncols=3, figsize=figsize, gridspec_kw=dict(width_ratios=widths), + layout="constrained", ) return (fig, axes) diff --git a/pulser-core/pulser/register/register.py b/pulser-core/pulser/register/register.py index 11adf083a..596e36b4b 100644 --- a/pulser-core/pulser/register/register.py +++ b/pulser-core/pulser/register/register.py @@ -361,6 +361,7 @@ def draw( draw_half_radius=draw_half_radius, qubit_colors=qubit_colors, ) + if fig_name is not None: plt.savefig(fig_name, **kwargs_savefig) diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index 9fbcfeaf5..545aa32ae 100644 --- a/pulser-core/pulser/sequence/_seq_drawer.py +++ b/pulser-core/pulser/sequence/_seq_drawer.py @@ -331,7 +331,7 @@ def phase_str(phi: float) -> str: blockade_radius=35, draw_half_radius=True, ) - fig_reg.tight_layout(w_pad=6.5) + fig_reg.get_layout_engine().set(w_pad=6.5) for ax_reg, (ix, iy) in zip( axes_reg, combinations(np.arange(3), 2) From 2a645c6cfecae0d1a13d03b15c6a21be13519fbe Mon Sep 17 00:00:00 2001 From: Davide Gessa Date: Mon, 19 Jun 2023 18:40:47 +0200 Subject: [PATCH 07/19] Separate draw_samples and draw_sequence (#533) * separate draw_samples and draw_sequence * rebase * restore target rendering * fix linters and types * move measurement drawing and draw_interp_pts to draw_sequenece * - add time_slots of type target to ChannelSamples - transform inital_targets to a property - implements draw_samples and integrate it on draw_sequenece * Fix samples.py typing * remove useless comments * - fix _seq_drawer parameters names - fix ChannelSamples field name and order * fix linting * move optional register drawing to draw_samples * splitting of drawing of target regions * fix linters * move draw_phase_shifts outside the loop * remove duplicate code from draw_sequence * separate draw_channel_content from draw_samples * restore _seq_drawer boxes definition position * minor edits on seq_drawer * adapt draw_phase_area for using sampled_seq in _seq_drawer * - move gather_data to draw_channel_content - minor refactoring * - add _basis_ref to SequenceSamples - move drawing of phase_shifts to _draw_channel_content * - move phase_str into _draw_channel_content * refactoring of _seq_drawer * Refactoring of _seq_drawer.py * fix typo * fix EOM drawing in draw_sequence * remove useless if * - test_draw_samples - rename _TargetSlot to _PulseTargetSlot - fix duration for draw_samples - fix eom_buffers creation - fix draw_phase_area * - add eom_start_buffers and eom_end_buffers in ChannelSamples - create those buffer in Schedule.get_samples - Adapt _seq_drawer gather_data for the new buffers - add a test assert for ChannelSamples.in_eom_mode * preserve backward compatibility for _TargetSlot * Pin numpy version to < 1.25 * use eom_blocks for eom_intervals_ti creation --- pulser-core/pulser/sampler/sampler.py | 1 + pulser-core/pulser/sampler/samples.py | 45 ++- pulser-core/pulser/sequence/_schedule.py | 51 ++- pulser-core/pulser/sequence/_seq_drawer.py | 382 +++++++++++------- pulser-core/requirements.txt | 2 +- .../pulser_simulation/simulation.py | 4 +- tests/test_sequence.py | 5 +- tests/test_sequence_sampler.py | 20 + 8 files changed, 356 insertions(+), 154 deletions(-) diff --git a/pulser-core/pulser/sampler/sampler.py b/pulser-core/pulser/sampler/sampler.py index d291987e0..d528d6b1c 100644 --- a/pulser-core/pulser/sampler/sampler.py +++ b/pulser-core/pulser/sampler/sampler.py @@ -56,5 +56,6 @@ def sample( list(seq.declared_channels.keys()), samples_list, seq.declared_channels, + seq._basis_ref, **optionals, ) diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 8a8a05fc2..625ec0fe6 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -10,9 +10,10 @@ from pulser.channels.base_channel import Channel from pulser.channels.eom import BaseEOM from pulser.register import QubitId +from pulser.sequence._basis_ref import _QubitRef if TYPE_CHECKING: - from pulser.sequence._schedule import _EOMSettings + from pulser.sequence._schedule import _EOMSettings, _TimeSlot """Literal constants for addressing.""" _GLOBAL = "Global" @@ -58,7 +59,7 @@ def _default_to_regular(d: dict | defaultdict) -> dict: @dataclass -class _TargetSlot: +class _PulseTargetSlot: """Auxiliary class to store target information. Recopy of the sequence._TimeSlot but without the unrelevant `type` field, @@ -89,9 +90,11 @@ class ChannelSamples: amp: np.ndarray det: np.ndarray phase: np.ndarray - slots: list[_TargetSlot] = field(default_factory=list) + slots: list[_PulseTargetSlot] = field(default_factory=list) eom_blocks: list[_EOMSettings] = field(default_factory=list) - initial_targets: set[QubitId] = field(default_factory=set) + 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) def __post_init__(self) -> None: assert len(self.amp) == len(self.det) == len(self.phase) @@ -102,6 +105,15 @@ def __post_init__(self) -> None: for t1, t2 in zip(self.slots, self.slots[1:]): assert t1.tf <= t2.ti # no overlaps on a given channel + @property + def initial_targets(self) -> set[QubitId]: + """Returns the initial targets.""" + return ( + self.target_time_slots[0].targets + if self.target_time_slots + else set() + ) + def extend_duration(self, new_duration: int) -> ChannelSamples: """Extends the duration of the samples. @@ -160,6 +172,23 @@ def _generate_std_samples(self) -> ChannelSamples: return replace(self, **new_samples) + def get_eom_mode_intervals(self) -> list[tuple[int, int]]: + """Returns EOM mode intervals.""" + return [ + ( + block.ti, + block.tf if block.tf is not None else self.duration, + ) + for block in self.eom_blocks + ] + + def in_eom_mode(self, slot: _TimeSlot | _PulseTargetSlot) -> bool: + """States if a time slot is inside an EOM mode block.""" + return any( + start <= slot.ti < end + for start, end in self.get_eom_mode_intervals() + ) + def modulate( self, channel_obj: Channel, max_duration: Optional[int] = None ) -> ChannelSamples: @@ -292,6 +321,9 @@ class SequenceSamples: channels: list[str] samples_list: list[ChannelSamples] _ch_objs: dict[str, Channel] + _basis_ref: dict[str, dict[QubitId, _QubitRef]] = field( + default_factory=dict + ) _slm_mask: _SlmMask = field(default_factory=_SlmMask) _magnetic_field: np.ndarray | None = None _measurement: str | None = None @@ -396,3 +428,8 @@ def __repr__(self) -> str: for chname, cs in zip(self.channels, self.samples_list) ] return "\n\n".join(blocks) + + +# This is just to preserve backwards compatibility after the renaming of +# _TargetSlot to _PulseTarget slot +_TargetSlot = _PulseTargetSlot diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index 010e1dbe2..eb2e0f774 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -24,7 +24,7 @@ from pulser.channels.base_channel import Channel from pulser.pulse import Pulse from pulser.register.base_register import QubitId -from pulser.sampler.samples import ChannelSamples, _TargetSlot +from pulser.sampler.samples import ChannelSamples, _PulseTargetSlot from pulser.waveforms import ConstantWaveform @@ -137,8 +137,17 @@ def get_samples( channel_slots = [s for s in self.slots if isinstance(s.type, Pulse)] dt = self.get_duration() amp, det, phase = np.zeros(dt), np.zeros(dt), np.zeros(dt) - slots: list[_TargetSlot] = [] - initial_targets = self.slots[0].targets if self.slots else set() + slots: list[_PulseTargetSlot] = [] + target_time_slots: list[_TimeSlot] = [ + s for s in self.slots if s.type == "target" + ] + # Extracting the EOM Buffers + eom_intervals_ti = [block.ti for block in self.eom_blocks] + nb_eom_intervals = len(eom_intervals_ti) + eom_start_buffers = [(0, 0) for _ in range(nb_eom_intervals)] + eom_end_buffers = [(0, 0) for _ in range(nb_eom_intervals)] + in_eom_mode = False + eom_block_n = -1 for ind, s in enumerate(channel_slots): pulse = cast(Pulse, s.type) @@ -156,7 +165,7 @@ def get_samples( if ind < len(channel_slots) - 1 else fall_time ) - slots.append(_TargetSlot(s.ti, tf, s.targets)) + slots.append(_PulseTargetSlot(s.ti, tf, s.targets)) if ignore_detuned_delay_phase and self.is_detuned_delay(pulse): # The phase of detuned delays is not considered @@ -182,8 +191,40 @@ def get_samples( # the same, so the last phase is automatically kept till the end phase[t_start:] = pulse.phase + # Create EOM start and end buffers + for s in self.slots: + if s.ti == -1: + continue + + # If slot is not the first element in schedule + if self.in_eom_mode(s): + # EOM mode starts + if not in_eom_mode: + in_eom_mode = True + eom_block_n += 1 + elif in_eom_mode: + # Buffer when EOM mode is disabled and next slot has 0 amp + in_eom_mode = False + if amp[s.ti] == 0: + eom_end_buffers[eom_block_n] = (s.ti, s.tf) + if ( + eom_block_n + 1 < nb_eom_intervals + and s.tf == eom_intervals_ti[eom_block_n + 1] + and det[s.tf - 1] + == self.eom_blocks[eom_block_n + 1].detuning_off + ): + # Buffer if next is eom and final det matches det_off + eom_start_buffers[eom_block_n + 1] = (s.ti, s.tf) + return ChannelSamples( - amp, det, phase, slots, self.eom_blocks, initial_targets + amp, + det, + phase, + slots, + self.eom_blocks, + eom_start_buffers, + eom_end_buffers, + target_time_slots, ) @overload diff --git a/pulser-core/pulser/sequence/_seq_drawer.py b/pulser-core/pulser/sequence/_seq_drawer.py index 545aa32ae..536dc0977 100644 --- a/pulser-core/pulser/sequence/_seq_drawer.py +++ b/pulser-core/pulser/sequence/_seq_drawer.py @@ -29,7 +29,9 @@ from pulser import Register, Register3D from pulser.channels.base_channel import Channel from pulser.pulse import Pulse -from pulser.sampler.samples import ChannelSamples +from pulser.register.base_register import BaseRegister +from pulser.sampler.sampler import sample +from pulser.sampler.samples import ChannelSamples, SequenceSamples from pulser.waveforms import InterpolatedWaveform # Color scheme @@ -161,82 +163,52 @@ def _give_curves_from_samples( ] -def gather_data(seq: pulser.sequence.Sequence, gather_output: bool) -> dict: +def gather_data( + sampled_seq: SequenceSamples, shown_duration: Optional[int] = None +) -> dict: """Collects the whole sequence data for plotting. Args: - seq: The input sequence of operations on a device. - gather_output: Whether to gather the modulated output curves. + sampled_seq: The samples of a sequence of operations on a device. + shown_duration: If present, is the total duration to be shown in + the X axis. Returns: The data to plot. """ # The minimum time axis length is 100 ns - total_duration = max( - seq.get_duration(include_fall_time=gather_output), 100 - ) + total_duration = max(sampled_seq.max_duration, 100, shown_duration or 100) data: dict[str, Any] = {} - for ch, sch in seq._schedule.items(): - # List of interpolation points - interp_pts: defaultdict[str, list[list[float]]] = defaultdict(list) + for ch, ch_samples in sampled_seq.channel_samples.items(): target: dict[Union[str, tuple[int, int]], Any] = {} # Extracting the EOM Buffers eom_intervals = [ EOMSegment(eom_interval[0], eom_interval[1]) - for eom_interval in sch.get_eom_mode_intervals() + for eom_interval in ch_samples.get_eom_mode_intervals() ] - nb_eom_intervals = len(eom_intervals) - eom_start_buffers = [EOMSegment() for _ in range(nb_eom_intervals)] - eom_end_buffers = [EOMSegment() for _ in range(nb_eom_intervals)] - in_eom_mode = False - eom_block_n = -1 # Last eom interval is extended if eom mode not disabled at the end - if nb_eom_intervals > 0 and seq.get_duration() == eom_intervals[-1].tf: + if ( + len(eom_intervals) > 0 + and ch_samples.duration == eom_intervals[-1].tf + ): eom_intervals[-1].tf = total_duration # sampling the channel schedule - samples = sch.get_samples() - extended_samples = samples.extend_duration(total_duration) - for slot in sch: - if slot.ti == -1: - target["initial"] = slot.targets - continue - else: - # If slot is not the first element in schedule - if sch.in_eom_mode(slot): - # EOM mode starts - if not in_eom_mode: - in_eom_mode = True - eom_block_n += 1 - elif in_eom_mode: - # Buffer when EOM mode is disabled and next slot has 0 amp - in_eom_mode = False - if extended_samples.amp[slot.ti] == 0: - eom_end_buffers[eom_block_n] = EOMSegment( - slot.ti, slot.tf - ) - if ( - eom_block_n + 1 < nb_eom_intervals - and slot.tf == eom_intervals[eom_block_n + 1].ti - and extended_samples.det[slot.tf - 1] - == sch.eom_blocks[eom_block_n + 1].detuning_off - ): - # Buffer if next is eom and final det matches det_off - eom_start_buffers[eom_block_n + 1] = EOMSegment( - slot.ti, slot.tf - ) + extended_samples = ch_samples.extend_duration(total_duration) - if slot.type == "target": - target[(slot.ti, slot.tf - 1)] = slot.targets - continue - if slot.type == "delay": + eom_start_buffers = [ + EOMSegment(eom_interval[0], eom_interval[1]) + for eom_interval in ch_samples.eom_start_buffers + ] + eom_end_buffers = [ + EOMSegment(eom_interval[0], eom_interval[1]) + for eom_interval in ch_samples.eom_end_buffers + ] + + for time_slot in ch_samples.target_time_slots: + if time_slot.ti == -1: + target["initial"] = time_slot.targets continue - pulse = cast(Pulse, slot.type) - for wf_type in ["amplitude", "detuning"]: - wf = getattr(pulse, wf_type) - if isinstance(wf, InterpolatedWaveform): - pts = wf.data_points - pts[:, 0] += slot.ti - interp_pts[wf_type] += pts.tolist() + target[(time_slot.ti, time_slot.tf - 1)] = time_slot.targets # Store everything data[ch] = ChannelDrawContent( @@ -246,43 +218,39 @@ def gather_data(seq: pulser.sequence.Sequence, gather_output: bool) -> dict: eom_start_buffers, eom_end_buffers, ) - if interp_pts: - data[ch].interp_pts = dict(interp_pts) - if hasattr(seq, "_measurement"): - data["measurement"] = seq._measurement + + if sampled_seq._measurement is not None: + data["measurement"] = sampled_seq._measurement data["total_duration"] = total_duration return data -def draw_sequence( - seq: pulser.sequence.Sequence, +def _draw_channel_content( + sampled_seq: SequenceSamples, + register: Optional[BaseRegister] = None, sampling_rate: Optional[float] = None, draw_phase_area: bool = False, - draw_interp_pts: bool = True, draw_phase_shifts: bool = False, - draw_register: bool = False, draw_input: bool = True, draw_modulation: bool = False, draw_phase_curve: bool = False, -) -> tuple[Figure | None, Figure]: - """Draws the entire sequence. + shown_duration: Optional[int] = None, +) -> tuple[Figure | None, Figure, Any, dict]: + """Draws samples of a sequence. Args: - seq: The input sequence of operations on a device. + sampled_seq: The input samples of a sequence of operations. + register: If present, draw the register before the pulse + sequence, with a visual indication (square halo) around the qubits + masked by the SLM. sampling_rate: Sampling rate of the effective pulse used by the solver. If present, plots the effective pulse alongside the input pulse. draw_phase_area: Whether phase and area values need to be shown as text on the plot, defaults to False. If `draw_phase_curve=True`, phase values are ommited. - draw_interp_pts: When the sequence has pulses with waveforms of - type InterpolatedWaveform, draws the points of interpolation on - top of the respective waveforms (defaults to True). draw_phase_shifts: Whether phase shift and reference information should be added to the plot, defaults to False. - draw_register: Whether to draw the register before the pulse - sequence, with a visual indication (square halo) around the qubits - masked by the SLM, defaults to False. draw_input: Draws the programmed pulses on the channels, defaults to True. draw_modulation: Draws the expected channel output, defaults to @@ -290,6 +258,7 @@ def draw_sequence( is skipped unless 'draw_input=False'. draw_phase_curve: Draws the changes in phase in its own curve (ignored if the phase doesn't change throughout the channel). + shown_duration: Total duration to be shown in the X axis. """ def phase_str(phi: float) -> str: @@ -302,13 +271,14 @@ def phase_str(phi: float) -> str: else: return rf"{value:.2g}$\pi$" - n_channels = len(seq.declared_channels) + n_channels = len(sampled_seq.channels) if not n_channels: raise RuntimeError("Can't draw an empty sequence.") - data = gather_data(seq, gather_output=draw_modulation) + + data = gather_data(sampled_seq, shown_duration) total_duration = data["total_duration"] time_scale = 1e3 if total_duration > 1e4 else 1 - for ch in seq._schedule: + for ch in sampled_seq.channels: 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: @@ -322,11 +292,11 @@ def phase_str(phi: float) -> str: eom_box = dict(boxstyle="round", facecolor="lightsteelblue") # Draw masked register - if draw_register: - pos = np.array(seq.register._coords) - if isinstance(seq.register, Register3D): + if register: + pos = np.array(register._coords) + if isinstance(register, Register3D): labels = "xyz" - fig_reg, axes_reg = seq.register._initialize_fig_axes_projection( + fig_reg, axes_reg = register._initialize_fig_axes_projection( pos, blockade_radius=35, draw_half_radius=True, @@ -336,12 +306,12 @@ def phase_str(phi: float) -> str: for ax_reg, (ix, iy) in zip( axes_reg, combinations(np.arange(3), 2) ): - seq.register._draw_2D( + register._draw_2D( ax=ax_reg, pos=pos, - ids=seq.register._ids, + ids=register._ids, plane=(ix, iy), - masked_qubits=seq._slm_mask_targets, + masked_qubits=sampled_seq._slm_mask.targets, ) ax_reg.set_title( "Masked register projected onto\n the " @@ -350,22 +320,22 @@ def phase_str(phi: float) -> str: + "-plane" ) - elif isinstance(seq.register, Register): - fig_reg, ax_reg = seq.register._initialize_fig_axes( + elif isinstance(register, Register): + fig_reg, ax_reg = register._initialize_fig_axes( pos, blockade_radius=35, draw_half_radius=True, ) - seq.register._draw_2D( + register._draw_2D( ax=ax_reg, pos=pos, - ids=seq.register._ids, - masked_qubits=seq._slm_mask_targets, + ids=register._ids, + masked_qubits=sampled_seq._slm_mask.targets, ) ax_reg.set_title("Masked register", pad=10) ratios = [ - SIZE_PER_WIDTH[data[ch].n_axes_on] for ch in seq.declared_channels + SIZE_PER_WIDTH[data[ch].n_axes_on] for ch in sampled_seq.channels ] fig = plt.figure( constrained_layout=False, @@ -374,7 +344,7 @@ def phase_str(phi: float) -> str: gs = fig.add_gridspec(n_channels, 1, hspace=0.075, height_ratios=ratios) ch_axes = {} - for i, (ch, gs_) in enumerate(zip(seq.declared_channels, gs)): + for i, (ch, gs_) in enumerate(zip(sampled_seq.channels, gs)): ax = fig.add_subplot(gs_) for side in ("top", "bottom", "left", "right"): ax.spines[side].set_color("none") @@ -412,8 +382,8 @@ def phase_str(phi: float) -> str: t_max = final_t * 1.05 for ch, axes in ch_axes.items(): - ch_obj = seq.declared_channels[ch] ch_data = data[ch] + ch_obj = sampled_seq._ch_objs[ch] ch_eom_intervals = data[ch].eom_intervals ch_eom_start_buffers = data[ch].eom_start_buffers ch_eom_end_buffers = data[ch].eom_end_buffers @@ -490,49 +460,54 @@ def phase_str(phi: float) -> str: if draw_phase_area: top = False # Variable to track position of box, top or center. print_phase = not draw_phase_curve and any( - seq_.type.phase != 0 - for seq_ in seq._schedule[ch] - if isinstance(seq_.type, Pulse) + np.any(ch_data.samples.phase[slot.ti : slot.tf] != 0) + for slot in ch_data.samples.slots ) - for pulse_num, seq_ in enumerate(seq._schedule[ch]): - # Select only `Pulse` objects - if isinstance(seq_.type, Pulse): - if sampling_rate: - area_val = ( - np.sum(yseff[0][seq_.ti : seq_.tf]) * 1e-3 / np.pi - ) - else: - area_val = seq_.type.amplitude.integral / np.pi - phase_val = seq_.type.phase - x_plot = (seq_.ti + seq_.tf) / 2 / time_scale - if ( - seq._schedule[ch][pulse_num - 1].type == "target" - or not top - ): - y_plot = np.max(seq_.type.amplitude.samples) / 2 - top = True # Next box at the top. - elif top: - y_plot = np.max(seq_.type.amplitude.samples) - top = False # Next box at the center. - area_fmt = ( - r"A: $\pi$" - if round(area_val, 2) == 1 - else rf"A: {area_val:.2g}$\pi$" + + for slot in ch_data.samples.slots: + if sampling_rate: + area_val = ( + np.sum(yseff[0][slot.ti : slot.tf]) * 1e-3 / np.pi ) - if not print_phase: - txt = area_fmt - else: - phase_fmt = rf"$\phi$: {phase_str(phase_val)}" - txt = "\n".join([phase_fmt, area_fmt]) - axes[0].text( - x_plot, - y_plot, - txt, - fontsize=10, - ha="center", - va="center", - bbox=area_ph_box, + else: + area_val = ( + np.sum(ch_data.samples.amp[slot.ti : slot.tf]) + * 1e-3 + / np.pi ) + phase_val = ch_data.samples.phase[slot.tf - 1] + x_plot = (slot.ti + slot.tf) / 2 / time_scale + target_slot_tf_list = [ + target_slot.tf + for target_slot in sampled_seq.channel_samples[ + ch + ].target_time_slots + ] + if slot.ti in target_slot_tf_list or not top: + y_plot = np.max(ch_data.samples.amp[slot.ti : slot.tf]) / 2 + top = True # Next box at the top. + elif top: + y_plot = np.max(ch_data.samples.amp[slot.ti : slot.tf]) + top = False # Next box at the center. + area_fmt = ( + r"A: $\pi$" + if round(area_val, 2) == 1 + else rf"A: {area_val:.2g}$\pi$" + ) + if not print_phase: + txt = area_fmt + else: + phase_fmt = rf"$\phi$: {phase_str(phase_val)}" + txt = "\n".join([phase_fmt, area_fmt]) + axes[0].text( + x_plot, + y_plot, + txt, + fontsize=10, + ha="center", + va="center", + bbox=area_ph_box, + ) target_regions = [] # [[start1, [targets1], end1],...] for coords in ch_data.target: @@ -543,7 +518,7 @@ def phase_str(phi: float) -> str: if coords == "initial": x = t_min + final_t * 0.005 target_regions.append([0, targets]) - if seq.declared_channels[ch].addressing == "Global": + if ch_obj.addressing == "Global": axes[0].text( x, amp_top * 0.98, @@ -563,7 +538,7 @@ def phase_str(phi: float) -> str: ha="left", bbox=q_box, ) - phase = seq._basis_ref[basis][targets[0]].phase[0] + phase = sampled_seq._basis_ref[basis][targets[0]].phase[0] if phase and draw_phase_shifts: msg = r"$\phi=$" + phase_str(phase) axes[0].text( @@ -580,7 +555,7 @@ def phase_str(phi: float) -> str: target_regions.append( [tf + 1 / time_scale, targets] ) # New one - phase = seq._basis_ref[basis][targets[0]].phase[ + phase = sampled_seq._basis_ref[basis][targets[0]].phase[ tf * time_scale + 1 ] for ax in axes: @@ -605,6 +580,7 @@ def phase_str(phi: float) -> str: fontsize=12, bbox=ph_box, ) + # Terminate the last open regions if target_regions: target_regions[-1].append(final_t) @@ -616,7 +592,7 @@ def phase_str(phi: float) -> str: end = cast(float, end) # All targets have the same ref, so we pick q = targets_[0] - ref = seq._basis_ref[basis][q].phase + ref = sampled_seq._basis_ref[basis][q].phase if end != total_duration - 1 or "measurement" in data: end += 1 / time_scale for t_, delta in ref.changes(start, end, time_scale=time_scale): @@ -653,11 +629,11 @@ def phase_str(phi: float) -> str: bbox=eom_box, ) # Draw the SLM mask - if seq._slm_mask_targets and seq._slm_mask_time: - tf_m = seq._slm_mask_time[1] + if sampled_seq._slm_mask.targets and sampled_seq._slm_mask.end: + tf_m = sampled_seq._slm_mask.end for ax in axes: ax.axvspan(0, tf_m, color="black", alpha=0.1, zorder=-100) - tgt_strs = [str(q) for q in seq._slm_mask_targets] + tgt_strs = [str(q) for q in sampled_seq._slm_mask.targets] tgt_txt_x = final_t * 0.005 tgt_txt_y = axes[-1].get_ylim()[0] tgt_str = "\n".join(tgt_strs) @@ -707,6 +683,130 @@ def phase_str(phi: float) -> str: if ax_lims[i][0] < 0: ax.axhline(0, **hline_kwargs) + return (fig_reg if register else None, fig, ch_axes, data) + + +def draw_samples( + sampled_seq: SequenceSamples, + register: Optional[BaseRegister] = None, + sampling_rate: Optional[float] = None, + draw_phase_area: bool = False, + draw_phase_shifts: bool = False, + draw_phase_curve: bool = False, +) -> tuple[Figure | None, Figure]: + """Draws a SequenceSamples. + + Args: + sampled_seq: The input samples of a sequence of operations. + register: If present, draw the register before the pulse + sequence samples, with a visual indication (square halo) + around the qubits masked by the SLM. + sampling_rate: Sampling rate of the effective pulse used by + the solver. If present, plots the effective pulse alongside the + input pulse. + draw_phase_area: Whether phase and area values need to be shown + as text on the plot, defaults to False. If `draw_phase_curve=True`, + phase values are ommited. + draw_phase_shifts: Whether phase shift and reference information + should be added to the plot, defaults to False. + draw_phase_curve: Draws the changes in phase in its own curve (ignored + if the phase doesn't change throughout the channel). + """ + slot_tfs = [ + ch_samples.slots[-1].tf + for ch_samples in sampled_seq.channel_samples.values() + ] + max_slot_tf = max(slot_tfs) if len(slot_tfs) > 0 else None + (fig_reg, fig, ch_axes, data) = _draw_channel_content( + sampled_seq, + register, + sampling_rate, + draw_phase_area, + draw_phase_shifts, + draw_input=True, + draw_modulation=False, + draw_phase_curve=draw_phase_curve, + shown_duration=max_slot_tf, + ) + + return (fig_reg, fig) + + +def draw_sequence( + seq: pulser.sequence.Sequence, + sampling_rate: Optional[float] = None, + draw_phase_area: bool = False, + draw_interp_pts: bool = True, + draw_phase_shifts: bool = False, + draw_register: bool = False, + draw_input: bool = True, + draw_modulation: bool = False, + draw_phase_curve: bool = False, +) -> tuple[Figure | None, Figure]: + """Draws the entire sequence. + + Args: + seq: The input sequence of operations on a device. + sampling_rate: Sampling rate of the effective pulse used by + the solver. If present, plots the effective pulse alongside the + input pulse. + draw_phase_area: Whether phase and area values need to be shown + as text on the plot, defaults to False. If `draw_phase_curve=True`, + phase values are ommited. + draw_interp_pts: When the sequence has pulses with waveforms of + type InterpolatedWaveform, draws the points of interpolation on + top of the respective waveforms (defaults to True). + draw_phase_shifts: Whether phase shift and reference information + should be added to the plot, defaults to False. + draw_register: Whether to draw the register before the pulse + sequence, with a visual indication (square halo) around the qubits + masked by the SLM, defaults to False. + draw_input: Draws the programmed pulses on the channels, defaults + to True. + draw_modulation: Draws the expected channel output, defaults to + False. If the channel does not have a defined 'mod_bandwidth', this + is skipped unless 'draw_input=False'. + draw_phase_curve: Draws the changes in phase in its own curve (ignored + if the phase doesn't change throughout the channel). + """ + # Sample the sequence and get the data to plot + shown_duration = seq.get_duration(include_fall_time=draw_modulation) + sampled_seq = sample(seq) + + (fig_reg, fig, ch_axes, data) = _draw_channel_content( + sampled_seq, + seq.register if draw_register else None, + sampling_rate, + draw_phase_area, + draw_phase_shifts, + draw_input, + draw_modulation, + draw_phase_curve, + shown_duration, + ) + + # Gather additional data for sequence specific drawing + for ch, sch in seq._schedule.items(): + interp_pts: defaultdict[str, list[list[float]]] = defaultdict(list) + + for slot in sch: + if slot.ti == -1 or slot.type in ["target", "delay"]: + continue + + pulse = cast(Pulse, slot.type) + for wf_type in ["amplitude", "detuning"]: + wf = getattr(pulse, wf_type) + if isinstance(wf, InterpolatedWaveform): + pts = wf.data_points + pts[:, 0] += slot.ti + interp_pts[wf_type] += pts.tolist() + + if interp_pts: + data[ch].interp_pts = dict(interp_pts) + + for ch, axes in ch_axes.items(): + ch_data = data[ch] + if draw_interp_pts: for qty in ("amplitude", "detuning"): if qty in ch_data.interp_pts and ch_data.curves_on[qty]: @@ -714,4 +814,4 @@ def phase_str(phi: float) -> str: pts = np.array(ch_data.interp_pts[qty]) axes[ind].scatter(pts[:, 0], pts[:, 1], color=COLORS[ind]) - return (fig_reg if draw_register else None, fig) + return (fig_reg, fig) diff --git a/pulser-core/requirements.txt b/pulser-core/requirements.txt index 2621a2ccb..b932eddef 100644 --- a/pulser-core/requirements.txt +++ b/pulser-core/requirements.txt @@ -1,5 +1,5 @@ jsonschema matplotlib # Numpy 1.20 introduces type hints, 1.24.0 breaks matplotlib < 3.6.1 -numpy >= 1.20, != 1.24.0 +numpy >= 1.20, != 1.24.0, <1.25 scipy diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index bd3a1a67c..40916ebba 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -32,7 +32,7 @@ from pulser.devices._device_datacls import BaseDevice from pulser.register.base_register import BaseRegister, QubitId from pulser.result import SampledResult -from pulser.sampler.samples import SequenceSamples, _TargetSlot +from pulser.sampler.samples import SequenceSamples, _PulseTargetSlot from pulser.sequence._seq_drawer import draw_sequence from pulser_simulation.qutip_result import QutipResult from pulser_simulation.simconfig import SimConfig @@ -500,7 +500,7 @@ def _extract_samples(self) -> None: samples = self.samples_obj.to_nested_dict(all_local=local_noises) def add_noise( - slot: _TargetSlot, + slot: _PulseTargetSlot, samples_dict: Mapping[QubitId, dict[str, np.ndarray]], is_global_pulse: bool, ) -> None: diff --git a/tests/test_sequence.py b/tests/test_sequence.py index 837d3d44b..c29c7f372 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -556,7 +556,7 @@ def test_switch_device_up( @pytest.mark.parametrize("mappable_reg", [False, True]) @pytest.mark.parametrize("parametrized", [False, True]) -def test_switch_device_eom(reg, mappable_reg, parametrized): +def test_switch_device_eom(reg, mappable_reg, parametrized, patch_plt_show): # Sequence with EOM blocks seq = init_seq( reg, @@ -602,6 +602,9 @@ def test_switch_device_eom(reg, mappable_reg, parametrized): assert og_eom_block.rabi_freq == mod_eom_block.rabi_freq assert og_eom_block.detuning_off != mod_eom_block.detuning_off + # Test drawing in eom mode + seq.draw() + def test_target(reg, device): seq = Sequence(reg, device) diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index fb253fd0c..9ae7e58a8 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -23,6 +23,7 @@ from pulser.devices import Device, MockDevice from pulser.pulse import Pulse from pulser.sampler import sample +from pulser.sequence._seq_drawer import draw_samples from pulser.waveforms import BlackmanWaveform, RampWaveform # Helpers @@ -239,6 +240,9 @@ def test_eom_modulation(mod_device, disable_eom): input_samples = sample( seq, extended_duration=full_duration ).channel_samples["ch0"] + assert input_samples.in_eom_mode(input_samples.slots[-1]) == ( + not disable_eom + ) mod_samples = sample(seq, modulation=True, extended_duration=full_duration) chan = seq.declared_channels["ch0"] for qty in ("amp", "det"): @@ -399,6 +403,22 @@ def test_phase_sampling(mod_device): np.testing.assert_array_equal(expected_phase, got_phase) +@pytest.mark.parametrize("modulation", [True, False]) +@pytest.mark.parametrize("draw_phase_area", [True, False]) +@pytest.mark.parametrize("draw_phase_shifts", [True, False]) +@pytest.mark.parametrize("draw_phase_curve", [True, False]) +def test_draw_samples( + mod_seq, modulation, draw_phase_area, draw_phase_curve, draw_phase_shifts +): + sampled_seq = sample(mod_seq, modulation=modulation) + draw_samples( + sampled_seq, + draw_phase_area=draw_phase_area, + draw_phase_shifts=draw_phase_shifts, + draw_phase_curve=draw_phase_curve, + ) + + # Fixtures From 8564b0b2af5a79707cae646559b0e41f2bd7bf50 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Tue, 20 Jun 2023 16:49:01 +0200 Subject: [PATCH 08/19] Bump to version 0.14dev1 --- .mypy.ini | 4 ++-- VERSION.txt | 2 +- pulser-core/pulser/json/supported.py | 3 ++- pulser-core/requirements.txt | 2 +- pulser-simulation/pulser_simulation/qutip_result.py | 2 +- tests/test_abstract_repr.py | 12 +++++++++++- tests/test_parametrized.py | 4 ++-- tests/test_waveforms.py | 4 ++-- 8 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.mypy.ini b/.mypy.ini index 3e575840c..e3dd5e8d6 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -4,14 +4,14 @@ files = pulser-simulation/pulser_simulation, pulser-pasqal/pulser_pasqal, tests -python_version = 3.8 +python_version = 3.11 warn_return_any = True warn_redundant_casts = True warn_unused_ignores = True disallow_untyped_defs = True # 3rd-party libs without type hints nor stubs -[mypy-matplotlib.*,scipy.*,qutip.*,jsonschema.*] +[mypy-matplotlib.*,scipy.*,qutip.*,jsonschema.*,py.*] follow_imports = silent ignore_missing_imports = True diff --git a/VERSION.txt b/VERSION.txt index 43f0d5936..02c2b66e6 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.14dev0 +0.14dev1 diff --git a/pulser-core/pulser/json/supported.py b/pulser-core/pulser/json/supported.py index de154fb7d..4824da638 100644 --- a/pulser-core/pulser/json/supported.py +++ b/pulser-core/pulser/json/supported.py @@ -36,7 +36,8 @@ SUPPORTED_NUMPY = ( "array", - "round_", + "round", # numpy >= 1.25 + "round_", # numpy < 1.25 "ceil", "floor", "sqrt", diff --git a/pulser-core/requirements.txt b/pulser-core/requirements.txt index b932eddef..2621a2ccb 100644 --- a/pulser-core/requirements.txt +++ b/pulser-core/requirements.txt @@ -1,5 +1,5 @@ jsonschema matplotlib # Numpy 1.20 introduces type hints, 1.24.0 breaks matplotlib < 3.6.1 -numpy >= 1.20, != 1.24.0, <1.25 +numpy >= 1.20, != 1.24.0 scipy diff --git a/pulser-simulation/pulser_simulation/qutip_result.py b/pulser-simulation/pulser_simulation/qutip_result.py index 30c4528b9..ccfae19b5 100644 --- a/pulser-simulation/pulser_simulation/qutip_result.py +++ b/pulser-simulation/pulser_simulation/qutip_result.py @@ -157,7 +157,7 @@ def get_state( is_density_matrix = state.isoper if ignore_global_phase and not is_density_matrix: full = state.full() - global_ph = float(np.angle(full[np.argmax(np.abs(full))])) + global_ph = float(np.angle(full[np.argmax(np.abs(full))])[0]) state *= np.exp(-1j * global_ph) if self._dim != 3: if reduce_to_basis not in [None, self._basis_name]: diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 1133ffa1d..c6dd5da8c 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -1583,7 +1583,17 @@ def test_deserialize_param(self, json_param): "var1": {"type": "float", "value": [1.5]}, }, ) - _check_roundtrip(s) + # Note: If built, some of these sequences will be invalid + # since they are giving an array of size 1 to a parameter + # where a single value is expected. Still, all we want to + # see is whether the parametrization of the operations + # works as expected + if ( + json_param["lhs"] != {"variable": "var1"} + and json_param["expression"] != "index" + ): + _check_roundtrip(s) + seq = Sequence.from_abstract_repr(json.dumps(s)) seq_var1 = seq._variables["var1"] diff --git a/tests/test_parametrized.py b/tests/test_parametrized.py index bee90fb1f..da9a58198 100644 --- a/tests/test_parametrized.py +++ b/tests/test_parametrized.py @@ -51,7 +51,7 @@ def t(): @pytest.fixture def bwf(t, a): - return BlackmanWaveform(t, a) + return BlackmanWaveform(t[0], a[0]) def test_var(a, b): @@ -113,7 +113,7 @@ def test_paramobj(bwf, t, a, b): assert set(bwf.variables.keys()) == {"t", "a"} pulse = Pulse.ConstantDetuning(bwf, b[0], b[1]) assert set(pulse.variables.keys()) == {"t", "a", "b"} - assert str(bwf) == "BlackmanWaveform(t, a)" + assert str(bwf) == "BlackmanWaveform(t[0], a[0])" assert str(pulse) == f"Pulse.ConstantDetuning({str(bwf)}, b[0], b[1])" pulse2 = Pulse(bwf, bwf, 1) assert str(pulse2) == f"Pulse({str(bwf)}, {str(bwf)}, 1)" diff --git a/tests/test_waveforms.py b/tests/test_waveforms.py index 6571ca695..c6dd85f37 100644 --- a/tests/test_waveforms.py +++ b/tests/test_waveforms.py @@ -171,7 +171,7 @@ def test_blackman(): wf = BlackmanWaveform(100, -2) assert np.isclose(wf.integral, -2) assert np.all(wf.samples <= 0) - assert wf == BlackmanWaveform(100, np.array([-2])) + assert wf == BlackmanWaveform(100, np.array(-2)) with pytest.raises(ValueError, match="matching signs"): BlackmanWaveform.from_max_val(-10, np.pi) @@ -185,7 +185,7 @@ def test_blackman(): assert np.min(wf.samples) > -10 var = Variable("var", float) - wf_var = BlackmanWaveform.from_max_val(-10, var) + wf_var = BlackmanWaveform.from_max_val(-10, var[0]) assert isinstance(wf_var, ParamObj) var._assign(-np.pi) assert wf_var.build() == wf From feafa3d2c2fb459283a298e405f9fa7e991a6acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Tue, 4 Jul 2023 11:34:55 +0200 Subject: [PATCH 09/19] Pin pydantic (#545) --- pulser-pasqal/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/pulser-pasqal/requirements.txt b/pulser-pasqal/requirements.txt index a86e8f5a3..f636822df 100644 --- a/pulser-pasqal/requirements.txt +++ b/pulser-pasqal/requirements.txt @@ -1 +1,2 @@ pasqal-cloud ~= 0.2.3 +pydantic < 2.0 From 5e36b3efd515b94fc3c645d91e832c9cabed9217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Thu, 6 Jul 2023 14:38:06 +0200 Subject: [PATCH 10/19] Pin jsonschema (#548) --- .../pulser/devices/interaction_coefficients/__init__.py | 3 ++- pulser-core/requirements.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pulser-core/pulser/devices/interaction_coefficients/__init__.py b/pulser-core/pulser/devices/interaction_coefficients/__init__.py index 31b0a0332..1c12ee4c1 100644 --- a/pulser-core/pulser/devices/interaction_coefficients/__init__.py +++ b/pulser-core/pulser/devices/interaction_coefficients/__init__.py @@ -23,5 +23,6 @@ import json from pathlib import PurePath -_json_dict = json.load(open(PurePath(__file__).parent / "C6_coeffs.json")) +with open(PurePath(__file__).parent / "C6_coeffs.json") as f: + _json_dict = json.load(f) c6_dict = {int(key): value for key, value in _json_dict.items()} diff --git a/pulser-core/requirements.txt b/pulser-core/requirements.txt index e1b171398..019c2b781 100644 --- a/pulser-core/requirements.txt +++ b/pulser-core/requirements.txt @@ -1,4 +1,4 @@ -jsonschema +jsonschema < 4.18 matplotlib # Numpy 1.20 introduces type hints, 1.24.0 breaks matplotlib < 3.6.1 numpy >= 1.20, != 1.24.0 From 7ac632763ef83aa427034b12f785444e1274ce0b Mon Sep 17 00:00:00 2001 From: Antoine Cornillot <61453516+a-corni@users.noreply.github.com> Date: Thu, 6 Jul 2023 18:07:11 +0200 Subject: [PATCH 11/19] Adding QutipEmulator.draw (#546) * Adding QutipEmulator.draw, deprecating Simulation * Changing Simulation to QutipEmulator in tests * Fixing typo * Fxing doc * Fixing typo and adding asserts --- .../pulser_simulation/simulation.py | 48 ++++- tests/test_sequence_sampler.py | 2 +- tests/test_simresults.py | 30 ++- tests/test_simulation.py | 192 +++++++++++------- .../State Preparation with the SLM Mask.ipynb | 4 +- .../Control-Z Gate Sequence.ipynb | 8 +- ...QAOA and QAA to solve a QUBO problem.ipynb | 8 +- ...ting Sequences with Errors and Noise.ipynb | 17 +- .../Simulating with SPAM errors.ipynb | 4 +- ...lating with effective noise channels.ipynb | 12 +- .../Simulating with laser noises.ipynb | 6 +- ... antiferromagnetic state preparation.ipynb | 8 +- .../Building 1D Rydberg Crystals.ipynb | 8 +- ...iltonians in arrays of Rydberg atoms.ipynb | 12 +- ...rromagnetic order in the Ising model.ipynb | 6 +- .../Shadow estimation for VQS.ipynb | 8 +- .../Spin chain of 3 atoms in XY mode.ipynb | 8 +- tutorials/simulating_sequences.ipynb | 6 +- 18 files changed, 242 insertions(+), 145 deletions(-) diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index 40916ebba..170c5d0b3 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -33,7 +33,7 @@ from pulser.register.base_register import BaseRegister, QubitId from pulser.result import SampledResult from pulser.sampler.samples import SequenceSamples, _PulseTargetSlot -from pulser.sequence._seq_drawer import draw_sequence +from pulser.sequence._seq_drawer import draw_samples, draw_sequence from pulser_simulation.qutip_result import QutipResult from pulser_simulation.simconfig import SimConfig from pulser_simulation.simresults import ( @@ -1042,6 +1042,44 @@ def _run_solver() -> CoherentResults: n_measures, ) + def draw( + self, + draw_phase_area: bool = False, + draw_phase_shifts: bool = False, + draw_phase_curve: bool = False, + fig_name: str | None = None, + kwargs_savefig: dict = {}, + ) -> None: + """Draws the samples of a sequence of operations used for simulation. + + Args: + draw_phase_area: Whether phase and area values need + to be shown as text on the plot, defaults to False. + draw_phase_shifts: Whether phase shift and reference + information should be added to the plot, defaults to False. + draw_phase_curve: Draws the changes in phase in its own curve + (ignored if the phase doesn't change throughout the channel). + fig_name: The name on which to save the figure. + If None the figure will not be saved. + kwargs_savefig: Keywords arguments for + ``matplotlib.pyplot.savefig``. Not applicable if `fig_name` + is ``None``. + + See Also: + Sequence.draw(): Draws the sequence in its current state. + """ + draw_samples( + self.samples_obj, + self._register, + self._sampling_rate, + draw_phase_area=draw_phase_area, + draw_phase_shifts=draw_phase_shifts, + draw_phase_curve=draw_phase_curve, + ) + if fig_name is not None: + plt.savefig(fig_name, **kwargs_savefig) + plt.show() + @classmethod def from_sequence( cls, @@ -1150,6 +1188,14 @@ def __init__( with_modulation: bool = False, ) -> None: """Instantiates a Simulation object.""" + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + DeprecationWarning( + "The `Simulation` class is deprecated," + " use `QutipEmulator.from_sequence` instead." + ) + ) self._seq = sequence self._modulated = with_modulation self._emulator = QutipEmulator.from_sequence( diff --git a/tests/test_sequence_sampler.py b/tests/test_sequence_sampler.py index 9ae7e58a8..511e3de2e 100644 --- a/tests/test_sequence_sampler.py +++ b/tests/test_sequence_sampler.py @@ -32,7 +32,7 @@ def assert_same_samples_as_sim(seq: pulser.Sequence) -> None: """Check against the legacy sample extraction in the simulation module.""" got = sample(seq).to_nested_dict() - want = pulser_simulation.Simulation(seq).samples.copy() + want = pulser_simulation.QutipEmulator.from_sequence(seq).samples.copy() def truncate_samples(samples_dict): for key, value in samples_dict.items(): diff --git a/tests/test_simresults.py b/tests/test_simresults.py index 765e90cc1..767146dc7 100644 --- a/tests/test_simresults.py +++ b/tests/test_simresults.py @@ -22,7 +22,7 @@ from pulser import Pulse, Register, Sequence from pulser.devices import Chadoq2, MockDevice from pulser.waveforms import BlackmanWaveform -from pulser_simulation import SimConfig, Simulation +from pulser_simulation import QutipEmulator, SimConfig from pulser_simulation.simresults import CoherentResults, NoisyResults @@ -52,7 +52,7 @@ def seq_no_meas(reg, pi_pulse): def sim(seq_no_meas): seq_no_meas.measure("ground-rydberg") np.random.seed(123) - return Simulation(seq_no_meas) + return QutipEmulator.from_sequence(seq_no_meas) @pytest.fixture @@ -108,7 +108,7 @@ def test_initialization(results): @pytest.mark.parametrize("noisychannel", [True, False]) def test_get_final_state( - noisychannel, sim: Simulation, results, reg, pi_pulse + noisychannel, sim: QutipEmulator, results, reg, pi_pulse ): if noisychannel: sim.add_config(SimConfig(noise="dephasing", dephasing_prob=0.01)) @@ -147,7 +147,7 @@ def test_get_final_state( seq_.add(pi_pulse, "ram") seq_.add(pi_pulse, "ryd") - sim_ = Simulation(seq_) + sim_ = QutipEmulator.from_sequence(seq_) results_ = sim_.run() results_ = cast(CoherentResults, results_) @@ -180,7 +180,7 @@ def test_get_final_state_noisy(reg, pi_pulse): seq_.declare_channel("ram", "raman_local", initial_target="A") seq_.add(pi_pulse, "ram") noisy_config = SimConfig(noise=("SPAM", "doppler")) - sim_noisy = Simulation(seq_, config=noisy_config) + sim_noisy = QutipEmulator.from_sequence(seq_, config=noisy_config) res3 = sim_noisy.run() res3._meas_basis = "digital" final_state = res3.get_final_state() @@ -229,7 +229,7 @@ def test_expect(results, pi_pulse, reg): seq_single = Sequence(reg_single, Chadoq2) seq_single.declare_channel("ryd", "rydberg_global") seq_single.add(pi_pulse, "ryd") - sim_single = Simulation(seq_single) + sim_single = QutipEmulator.from_sequence(seq_single) results_single = sim_single.run() op = [qutip.basis(2, 0).proj()] exp = results_single.expect(op)[0] @@ -242,10 +242,6 @@ def test_expect(results, pi_pulse, reg): config = SimConfig(noise="SPAM", eta=0) sim_single.set_config(config) - with pytest.warns( - DeprecationWarning, match="Setting `evaluation_times` is deprecated" - ): - sim_single.evaluation_times = "Minimal" sim_single.set_evaluation_times("Minimal") results_single = sim_single.run() exp = results_single.expect(op)[0] @@ -268,7 +264,7 @@ def test_expect(results, pi_pulse, reg): seq3dim.declare_channel("ram", "raman_local", initial_target="A") seq3dim.add(pi_pulse, "ram") seq3dim.add(pi_pulse, "ryd") - sim3dim = Simulation(seq3dim) + sim3dim = QutipEmulator.from_sequence(seq3dim) exp3dim = sim3dim.run().expect( [qutip.tensor(qutip.basis(3, 0).proj(), qutip.qeye(3))] ) @@ -293,7 +289,9 @@ def test_plot(results_noisy, results): def test_sim_without_measurement(seq_no_meas): assert not seq_no_meas.is_measured() - sim_no_meas = Simulation(seq_no_meas, config=SimConfig(runs=1)) + sim_no_meas = QutipEmulator.from_sequence( + seq_no_meas, config=SimConfig(runs=1) + ) results_no_meas = sim_no_meas.run() assert results_no_meas.sample_final_state() == Counter( {"00": 80, "01": 164, "10": 164, "11": 592} @@ -313,13 +311,13 @@ def test_sample_final_state(results): def test_sample_final_state_three_level(seq_no_meas, pi_pulse): seq_no_meas.declare_channel("raman", "raman_local", "B") seq_no_meas.add(pi_pulse, "raman") - res_3level = Simulation(seq_no_meas).run() + res_3level = QutipEmulator.from_sequence(seq_no_meas).run() # Raman pi pulse on one atom will not affect other, # even with global pi on rydberg assert len(res_3level.sample_final_state()) == 2 seq_no_meas.measure("ground-rydberg") - res_3level_gb = Simulation(seq_no_meas).run() + res_3level_gb = QutipEmulator.from_sequence(seq_no_meas).run() sampling_three_levelB = res_3level_gb.sample_final_state() # Rydberg will affect both: assert len(sampling_three_levelB) == 4 @@ -330,7 +328,7 @@ def test_sample_final_state_noisy(seq_no_meas, results_noisy): assert results_noisy.sample_final_state(N_samples=1234) == Counter( {"11": 772, "10": 190, "01": 161, "00": 111} ) - res_3level = Simulation( + res_3level = QutipEmulator.from_sequence( seq_no_meas, config=SimConfig(noise=("SPAM", "doppler"), runs=10) ) final_state = res_3level.run().states[-1] @@ -355,7 +353,7 @@ def test_results_xy(reg, pi_pulse): seq_.add(pi_pulse, "ch0") seq_.measure("XY") - sim_ = Simulation(seq_) + sim_ = QutipEmulator.from_sequence(seq_) results_ = sim_.run() assert results_._dim == 2 diff --git a/tests/test_simulation.py b/tests/test_simulation.py index e8d442650..28cb3a970 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -99,7 +99,7 @@ def test_bad_import(): def test_initialization_and_construction_of_hamiltonian(seq, mod_device): fake_sequence = {"pulse1": "fake", "pulse2": "fake"} with pytest.raises(TypeError, match="sequence has to be a valid"): - Simulation(fake_sequence) + QutipEmulator.from_sequence(fake_sequence) with pytest.raises(TypeError, match="sequence has to be a valid"): QutipEmulator(fake_sequence, Register.square(2), mod_device) # Simulation cannot be run on a register not defining "control1" @@ -117,19 +117,47 @@ def test_initialization_and_construction_of_hamiltonian(seq, mod_device): ), MockDevice, ) - sim = Simulation(seq, sampling_rate=0.011) - assert sim._seq == seq + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.011) + sampled_seq = sampler.sample(seq) + ext_sampled_seq = sampled_seq.extend_duration(sampled_seq.max_duration + 1) + assert np.all( + [ + np.equal( + sim.samples_obj.channel_samples[ch].amp, + ext_sampled_seq.channel_samples[ch].amp, + ) + for ch in sampled_seq.channels + ] + ) + assert np.all( + [ + np.equal( + sim.samples_obj.channel_samples[ch].det, + ext_sampled_seq.channel_samples[ch].det, + ) + for ch in sampled_seq.channels + ] + ) + assert np.all( + [ + np.equal( + sim.samples_obj.channel_samples[ch].phase, + ext_sampled_seq.channel_samples[ch].phase, + ) + for ch in sampled_seq.channels + ] + ) assert sim._qdict == seq.qubit_info assert sim._size == len(seq.qubit_info) assert sim._tot_duration == 9000 # seq has 9 pulses of 1µs assert sim._qid_index == {"control1": 0, "target": 1, "control2": 2} with pytest.raises(ValueError, match="too small, less than"): - Simulation(seq, sampling_rate=0.0001) + QutipEmulator.from_sequence(seq, sampling_rate=0.0001) with pytest.raises(ValueError, match="`sampling_rate`"): - Simulation(seq, sampling_rate=5) + QutipEmulator.from_sequence(seq, sampling_rate=5) with pytest.raises(ValueError, match="`sampling_rate`"): - Simulation(seq, sampling_rate=-1) + QutipEmulator.from_sequence(seq, sampling_rate=-1) assert sim._sampling_rate == 0.011 assert len(sim.sampling_times) == int( @@ -150,18 +178,18 @@ def test_initialization_and_construction_of_hamiltonian(seq, mod_device): seq_copy.add(Pulse.ConstantPulse(x, 1, 0, 0), "ryd") assert seq_copy.is_parametrized() with pytest.raises(ValueError, match="needs to be built"): - Simulation(seq_copy) + QutipEmulator.from_sequence(seq_copy) layout = RegisterLayout([[0, 0], [10, 10]]) mapp_reg = layout.make_mappable_register(1) seq_ = Sequence(mapp_reg, Chadoq2) assert seq_.is_register_mappable() and not seq_.is_parametrized() with pytest.raises(ValueError, match="needs to be built"): - Simulation(seq_) + QutipEmulator.from_sequence(seq_) def test_extraction_of_sequences(seq): - sim = Simulation(seq) + sim = QutipEmulator.from_sequence(seq) for channel in seq.declared_channels: addr = seq.declared_channels[channel].addressing basis = seq.declared_channels[channel].basis @@ -203,7 +231,7 @@ def test_extraction_of_sequences(seq): def test_building_basis_and_projection_operators(seq, reg): # All three levels: - sim = Simulation(seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) assert sim.basis_name == "all" assert sim.dim == 3 assert sim.basis == { @@ -242,7 +270,7 @@ def test_building_basis_and_projection_operators(seq, reg): seq2.declare_channel("global", "rydberg_global") pi_pls = Pulse.ConstantDetuning(BlackmanWaveform(1000, np.pi), 0.0, 0) seq2.add(pi_pls, "global") - sim2 = Simulation(seq2, sampling_rate=0.01) + sim2 = QutipEmulator.from_sequence(seq2, sampling_rate=0.01) assert sim2.basis_name == "ground-rydberg" assert sim2.dim == 2 assert sim2.basis == {"r": qutip.basis(2, 0), "g": qutip.basis(2, 1)} @@ -259,7 +287,7 @@ def test_building_basis_and_projection_operators(seq, reg): seq2b = Sequence(reg, Chadoq2) seq2b.declare_channel("local", "raman_local", "target") seq2b.add(pi_pls, "local") - sim2b = Simulation(seq2b, sampling_rate=0.01) + sim2b = QutipEmulator.from_sequence(seq2b, sampling_rate=0.01) assert sim2b.basis_name == "digital" assert sim2b.dim == 2 assert sim2b.basis == {"g": qutip.basis(2, 0), "h": qutip.basis(2, 1)} @@ -276,7 +304,7 @@ def test_building_basis_and_projection_operators(seq, reg): seq2c = Sequence(reg, Chadoq2) seq2c.declare_channel("local_ryd", "rydberg_local", "target") seq2c.add(pi_pls, "local_ryd") - sim2c = Simulation(seq2c, sampling_rate=0.01) + sim2c = QutipEmulator.from_sequence(seq2c, sampling_rate=0.01) assert sim2c.basis_name == "ground-rydberg" assert sim2c.dim == 2 assert sim2c.basis == {"r": qutip.basis(2, 0), "g": qutip.basis(2, 1)} @@ -299,7 +327,7 @@ def test_building_basis_and_projection_operators(seq, reg): match="Bases used in samples should be supported by device.", ): QutipEmulator(sampler.sample(seq2), seq2.register, Chadoq2) - sim2 = Simulation(seq2, sampling_rate=0.01) + sim2 = QutipEmulator.from_sequence(seq2, sampling_rate=0.01) assert sim2.basis_name == "XY" assert sim2.dim == 2 assert sim2.basis == {"u": qutip.basis(2, 0), "d": qutip.basis(2, 1)} @@ -320,10 +348,10 @@ def test_building_basis_and_projection_operators(seq, reg): def test_empty_sequences(reg): seq = Sequence(reg, MockDevice) with pytest.raises(ValueError, match="no declared channels"): - Simulation(seq) + QutipEmulator.from_sequence(seq) seq.declare_channel("ch0", "mw_global") with pytest.raises(ValueError, match="No instructions given"): - Simulation(seq) + QutipEmulator.from_sequence(seq) with pytest.raises(ValueError, match="SequenceSamples is empty"): QutipEmulator(sampler.sample(seq), seq.register, seq.device) @@ -331,7 +359,7 @@ def test_empty_sequences(reg): seq.declare_channel("test", "raman_local", "target") seq.declare_channel("test2", "rydberg_global") with pytest.raises(ValueError, match="No instructions given"): - Simulation(seq) + QutipEmulator.from_sequence(seq) seq.delay(100, "test") emu = QutipEmulator.from_sequence(seq, config=SimConfig(noise="SPAM")) @@ -350,7 +378,7 @@ def test_get_hamiltonian(): simple_seq.declare_channel("ising", "rydberg_global") simple_seq.add(rise, "ising") - simple_sim = Simulation(simple_seq, sampling_rate=0.01) + simple_sim = QutipEmulator.from_sequence(simple_seq, sampling_rate=0.01) with pytest.raises(ValueError, match="less than or equal to"): simple_sim.get_hamiltonian(1650) with pytest.raises(ValueError, match="greater than or equal to"): @@ -362,7 +390,7 @@ def test_get_hamiltonian(): ) np.random.seed(123) - simple_sim_noise = Simulation( + simple_sim_noise = QutipEmulator.from_sequence( simple_seq, config=SimConfig(noise="doppler", temperature=20000) ) simple_ham_noise = simple_sim_noise.get_hamiltonian(144) @@ -401,10 +429,10 @@ def test_single_atom_simulation(): one_seq.add( Pulse.ConstantDetuning(ConstantWaveform(16, 1.0), 1.0, 0), "ch0" ) - one_sim = Simulation(one_seq) + one_sim = QutipEmulator.from_sequence(one_seq) one_res = one_sim.run() assert one_res._size == one_sim._size - one_sim = Simulation(one_seq, evaluation_times="Minimal") + one_sim = QutipEmulator.from_sequence(one_seq, evaluation_times="Minimal") one_resb = one_sim.run() assert one_resb._size == one_sim._size @@ -419,7 +447,7 @@ def test_add_max_step_and_delays(): seq.add( Pulse.ConstantDetuning(BlackmanWaveform(600, np.pi / 2), 0, 0), "ch" ) - sim = Simulation(seq) + sim = QutipEmulator.from_sequence(seq) res_large_max_step = sim.run(max_step=1) res_auto_max_step = sim.run() r = qutip.basis(2, 0) @@ -430,7 +458,7 @@ def test_add_max_step_and_delays(): def test_run(seq, patch_plt_show): - sim = Simulation(seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) sim.set_config(SimConfig("SPAM", eta=0.0)) with patch("matplotlib.pyplot.savefig"): sim.draw(draw_phase_area=True, fig_name="my_fig.pdf") @@ -454,7 +482,12 @@ def test_run(seq, patch_plt_show): with pytest.warns( DeprecationWarning, match="Setting `initial_state` is deprecated" ): - sim.initial_state = good_initial_array + with pytest.warns( + DeprecationWarning, match="The `Simulation` class is deprecated" + ): + _sim = Simulation(seq, sampling_rate=0.01) + _sim.initial_state = good_initial_qobj + assert _sim.initial_state == good_initial_qobj sim.set_initial_state(good_initial_array) sim.run() @@ -463,8 +496,11 @@ def test_run(seq, patch_plt_show): sim.set_initial_state(good_initial_qobj_no_dims) sim.run() seq.measure("ground-rydberg") + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) + sim.set_initial_state(good_initial_qobj_no_dims) + sim.run() - assert sim._seq._measurement == "ground-rydberg" + assert sim.samples_obj._measurement == "ground-rydberg" sim.run(progress_bar=True) sim.run(progress_bar=False) @@ -488,20 +524,20 @@ def test_eval_times(seq): with pytest.raises( ValueError, match="evaluation_times float must be between 0 " "and 1." ): - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times(3.0) with pytest.raises(ValueError, match="Wrong evaluation time label."): - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times(123) with pytest.raises(ValueError, match="Wrong evaluation time label."): - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times("Best") with pytest.raises( ValueError, match="Provided evaluation-time list contains " "negative values.", ): - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times([-1, 0, sim.sampling_times[-2]]) with pytest.raises( @@ -509,14 +545,24 @@ def test_eval_times(seq): match="Provided evaluation-time list extends " "further than sequence duration.", ): - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times([0, sim.sampling_times[-1] + 10]) - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) with pytest.warns( DeprecationWarning, match="Setting `evaluation_times` is deprecated" ): - sim.evaluation_times = "Full" + with pytest.warns( + DeprecationWarning, match="The `Simulation` class is deprecated" + ): + _sim = Simulation(seq, sampling_rate=1.0) + _sim.evaluation_times = "Full" + assert np.array_equal( + _sim.evaluation_times, + np.union1d(_sim.sampling_times, [0.0, _sim._tot_duration / 1000]), + ) + assert _sim._eval_times_instruction == "Full" + sim.set_evaluation_times("Full") assert sim._eval_times_instruction == "Full" np.testing.assert_almost_equal( @@ -524,14 +570,14 @@ def test_eval_times(seq): sim.sampling_times, ) - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times("Minimal") np.testing.assert_almost_equal( sim._eval_times_array, np.array([sim.sampling_times[0], sim._tot_duration / 1000]), ) - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times( [ 0, @@ -556,7 +602,7 @@ def test_eval_times(seq): np.array([0, sim._tot_duration / 1000]), ) - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times([sim.sampling_times[-10], sim.sampling_times[-3]]) np.testing.assert_almost_equal( sim._eval_times_array, @@ -570,7 +616,7 @@ def test_eval_times(seq): ), ) - sim = Simulation(seq, sampling_rate=1.0) + sim = QutipEmulator.from_sequence(seq, sampling_rate=1.0) sim.set_evaluation_times(0.4) np.testing.assert_almost_equal( sim.sampling_times[ @@ -593,7 +639,7 @@ def test_config(): duration = 2500 pulse = Pulse.ConstantPulse(duration, np.pi, 0.0 * 2 * np.pi, 0) seq.add(pulse, "ch0") - sim = Simulation(seq, config=SimConfig(noise="SPAM")) + sim = QutipEmulator.from_sequence(seq, config=SimConfig(noise="SPAM")) sim.reset_config() assert sim.config == SimConfig() sim.show_config() @@ -618,7 +664,7 @@ def test_config(): def test_noise(seq, matrices): np.random.seed(3) - sim2 = Simulation( + sim2 = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig(noise=("SPAM"), eta=0.9) ) assert sim2.run().sample_final_state() == Counter( @@ -653,9 +699,9 @@ def test_noise(seq, matrices): def test_noise_with_zero_epsilons(seq, matrices): np.random.seed(3) - sim = Simulation(seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) - sim2 = Simulation( + sim2 = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig( @@ -679,7 +725,7 @@ def test_dephasing(): duration = 2500 pulse = Pulse.ConstantPulse(duration, np.pi, 0, 0) seq.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig(noise="dephasing") ) assert sim.run().sample_final_state() == Counter({"0": 595, "1": 405}) @@ -689,7 +735,7 @@ def test_dephasing(): seq2 = Sequence(reg, Chadoq2) seq2.declare_channel("ch0", "rydberg_global") seq2.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq2, sampling_rate=0.01, config=SimConfig(noise="dephasing", dephasing_prob=0.5), @@ -704,7 +750,7 @@ def test_depolarizing(): duration = 2500 pulse = Pulse.ConstantPulse(duration, np.pi, 0, 0) seq.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig(noise="depolarizing") ) assert sim.run().sample_final_state() == Counter({"0": 587, "1": 413}) @@ -716,7 +762,7 @@ def test_depolarizing(): seq2 = Sequence(reg, Chadoq2) seq2.declare_channel("ch0", "rydberg_global") seq2.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq2, sampling_rate=0.01, config=SimConfig(noise="depolarizing", depolarizing_prob=0.5), @@ -731,7 +777,7 @@ def test_eff_noise(matrices): duration = 2500 pulse = Pulse.ConstantPulse(duration, np.pi, 0, 0) seq.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig( @@ -740,7 +786,7 @@ def test_eff_noise(matrices): eff_noise_probs=[0.975, 0.025], ), ) - sim_dph = Simulation( + sim_dph = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig(noise="dephasing") ) assert ( @@ -753,7 +799,7 @@ def test_eff_noise(matrices): seq2 = Sequence(reg, Chadoq2) seq2.declare_channel("ch0", "rydberg_global") seq2.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq2, sampling_rate=0.01, config=SimConfig( @@ -771,7 +817,7 @@ def test_add_config(matrices): duration = 2500 pulse = Pulse.ConstantPulse(duration, np.pi, 0.0 * 2 * np.pi, 0) seq.add(pulse, "ch0") - sim = Simulation( + sim = QutipEmulator.from_sequence( seq, sampling_rate=0.01, config=SimConfig(noise="SPAM", eta=0.5) ) with pytest.raises(ValueError, match="is not a valid"): @@ -823,10 +869,10 @@ def test_concurrent_pulses(): seq.add(pulse, "ch_global", protocol="no-delay") # Clean simulation - sim_no_noise = Simulation(seq) + sim_no_noise = QutipEmulator.from_sequence(seq) # Noisy simulation - sim_with_noise = Simulation(seq) + sim_with_noise = QutipEmulator.from_sequence(seq) config_doppler = SimConfig(noise=("doppler")) sim_with_noise.set_config(config_doppler) @@ -850,7 +896,7 @@ def test_get_xy_hamiltonian(): assert np.isclose(np.linalg.norm(simple_seq.magnetic_field[0:2]), 1) - simple_sim = Simulation(simple_seq, sampling_rate=0.03) + simple_sim = QutipEmulator.from_sequence(simple_seq, sampling_rate=0.03) with pytest.raises( ValueError, match="less than or equal to the sequence duration" ): @@ -880,7 +926,7 @@ def test_run_xy(): simple_seq.declare_channel("ch0", "mw_global") simple_seq.add(rise, "ch0") - sim = Simulation(simple_seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(simple_seq, sampling_rate=0.01) good_initial_array = np.r_[1, np.zeros(sim.dim**sim._size - 1)] good_initial_qobj = qutip.tensor( @@ -892,10 +938,11 @@ def test_run_xy(): sim.set_initial_state(good_initial_qobj) sim.run() - assert not hasattr(sim._seq, "_measurement") + assert not sim.samples_obj._measurement simple_seq.measure(basis="XY") + sim = QutipEmulator.from_sequence(simple_seq, sampling_rate=0.01) sim.run() - assert sim._seq._measurement == "XY" + assert sim.samples_obj._measurement == "XY" def test_noisy_xy(): @@ -908,7 +955,7 @@ def test_noisy_xy(): simple_seq.declare_channel("ch0", "mw_global") simple_seq.add(rise, "ch0") - sim = Simulation(simple_seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(simple_seq, sampling_rate=0.01) with pytest.raises( NotImplementedError, match="mode 'XY' does not support simulation of" ): @@ -938,10 +985,11 @@ def test_mask_nopulses(): seq_empty.delay(duration=100, channel="ch") masked_qubits = ["q2"] seq_empty.config_slm_mask(masked_qubits) - sim_empty = Simulation(seq_empty) + sim_empty = QutipEmulator.from_sequence(seq_empty) assert seq_empty._slm_mask_time == [] - assert sim_empty._seq._slm_mask_time == [] + assert sampler.sample(seq_empty)._slm_mask.end == 0 + assert sim_empty.samples_obj._slm_mask.end == 0 def test_mask_equals_remove(): @@ -972,7 +1020,7 @@ def test_mask_equals_remove(): masked_qubits = ["q2"] seq_masked.config_slm_mask(masked_qubits) seq_masked.add(pulse, "ch_masked") - sim_masked = Simulation(seq_masked) + sim_masked = QutipEmulator.from_sequence(seq_masked) # Simulation cannot be run on a device not having an SLM mask with pytest.raises( ValueError, @@ -993,7 +1041,7 @@ def test_mask_equals_remove(): if channel_type != "mw_global": seq_two.delay(local_pulse.duration, "ch_two") seq_two.add(pulse, "ch_two") - sim_two = Simulation(seq_two) + sim_two = QutipEmulator.from_sequence(seq_two) # Check equality for t in sim_two.sampling_times: @@ -1022,7 +1070,7 @@ def test_mask_two_pulses(): seq_masked.add(pulse, "ch_masked") # First pulse: masked seq_masked.add(pulse, "ch_masked") # Second pulse: unmasked seq_masked.add(pulse, "ch_masked") # Third pulse: unmasked - sim_masked = Simulation(seq_masked) + sim_masked = QutipEmulator.from_sequence(seq_masked) # Unmasked simulation on full register seq_three = Sequence(reg_three, MockDevice) @@ -1030,7 +1078,7 @@ def test_mask_two_pulses(): seq_three.add(no_pulse, "ch_three") seq_three.add(pulse, "ch_three") seq_three.add(pulse, "ch_three") - sim_three = Simulation(seq_three) + sim_three = QutipEmulator.from_sequence(seq_three) # Unmasked simulation on reduced register seq_two = Sequence(reg_two, MockDevice) @@ -1038,7 +1086,7 @@ def test_mask_two_pulses(): seq_two.add(pulse, "ch_two") seq_two.add(no_pulse, "ch_two") seq_two.add(no_pulse, "ch_two") - sim_two = Simulation(seq_two) + sim_two = QutipEmulator.from_sequence(seq_two) ti = seq_masked._slm_mask_time[0] tf = seq_masked._slm_mask_time[1] @@ -1066,7 +1114,7 @@ def test_mask_local_channel(): assert seq_._slm_mask_time == [0, 1000] assert seq_._slm_mask_targets == {"q0", "q3"} - sim = Simulation(seq_) + sim = QutipEmulator.from_sequence(seq_) for qty in ("amp", "det", "phase"): assert np.all(sim.samples["Local"]["digital"]["q0"][qty] == 0.0) assert "q3" not in sim.samples["Local"]["digital"] @@ -1082,7 +1130,7 @@ def test_effective_size_intersection(): seq.add(rise, "ch0") seq.config_slm_mask(["atom0"]) - sim = Simulation(seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) sim.set_config(SimConfig("SPAM", eta=0.4)) assert sim._bad_atoms == { "atom0": True, @@ -1104,7 +1152,7 @@ def test_effective_size_disjoint(): seq.declare_channel("ch0", channel_type) seq.add(rise, "ch0") seq.config_slm_mask(["atom1"]) - sim = Simulation(seq, sampling_rate=0.01) + sim = QutipEmulator.from_sequence(seq, sampling_rate=0.01) sim.set_config(SimConfig("SPAM", eta=0.4)) assert sim._bad_atoms == { "atom0": True, @@ -1129,7 +1177,7 @@ def test_simulation_with_modulation(mod_device, reg, patch_plt_show): match="Simulation of sequences combining an SLM mask and output " "modulation is not supported.", ): - Simulation(seq, with_modulation=True) + QutipEmulator.from_sequence(seq, with_modulation=True) seq = Sequence(reg, mod_device) seq.declare_channel("ch0", "rydberg_global") @@ -1144,7 +1192,9 @@ def test_simulation_with_modulation(mod_device, reg, patch_plt_show): assert pulse1_mod_samples.size == mod_dt sim_config = SimConfig(("amplitude", "doppler")) - sim = Simulation(seq, with_modulation=True, config=sim_config) + sim = QutipEmulator.from_sequence( + seq, with_modulation=True, config=sim_config + ) assert sim.samples["Global"] == {} # All samples stored in local raman_samples = sim.samples["Local"]["digital"] @@ -1187,13 +1237,19 @@ def pos_factor(qid): np.testing.assert_allclose( rydberg_samples[qid]["phase"][time_slice], pulse1.phase ) + with pytest.warns( + DeprecationWarning, match="The `Simulation` class is deprecated" + ): + _sim = Simulation(seq, with_modulation=True, config=sim_config) with pytest.raises( ValueError, match="Can't draw the interpolation points when the sequence " "is modulated", ): - sim.draw(draw_interp_pts=True) + _sim.draw(draw_interp_pts=True) + with patch("matplotlib.pyplot.savefig"): + _sim.draw(draw_phase_area=True, fig_name="my_fig.pdf") # Drawing with modulation sim.draw() diff --git a/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb b/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb index ed0645e53..7dc05244a 100644 --- a/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb +++ b/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb @@ -33,7 +33,7 @@ "from pulser import Pulse, Sequence, Register\n", "from pulser.devices import MockDevice\n", "from pulser.waveforms import BlackmanWaveform\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "\n", "# Qubit register\n", "qubits = {\"q0\": (-5, 0), \"q1\": (0, 0), \"q2\": (5, 0)}\n", @@ -160,7 +160,7 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq)\n", + "sim = QutipEmulator.from_sequence(seq)\n", "results = sim.run()\n", "\n", "results.get_final_state()" diff --git a/tutorials/applications/Control-Z Gate Sequence.ipynb b/tutorials/applications/Control-Z Gate Sequence.ipynb index 7615c3e8d..e92b5f80f 100644 --- a/tutorials/applications/Control-Z Gate Sequence.ipynb +++ b/tutorials/applications/Control-Z Gate Sequence.ipynb @@ -57,7 +57,7 @@ "source": [ "from pulser import Pulse, Sequence, Register\n", "from pulser.devices import Chadoq2\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.waveforms import BlackmanWaveform, ConstantWaveform" ] }, @@ -72,7 +72,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Defining an atom register can simply be done by choosing one of the predetermined shapes included in the `Register`class. We can also construct a dictionary with specific labels for each atom. The atoms must lie inside the *Rydberg blockade radius* $R_b$, which we will characterize by \n", + "Defining an atom register can simply be done by choosing one of the predetermined shapes included in the `Register` class. We can also construct a dictionary with specific labels for each atom. The atoms must lie inside the *Rydberg blockade radius* $R_b$, which we will characterize by \n", "\n", "$$\\hbar \\Omega^{\\text{Max}}_{\\text{Rabi}} \\sim U_{ij} = \\frac{C_6}{R_{b}^6},$$\n", "\n", @@ -362,7 +362,7 @@ " ) # constructs seq, prep_state and prep_time\n", "\n", " # Construct Simulation instance\n", - " simul = Simulation(seq)\n", + " simul = QutipEmulator.from_sequence(seq)\n", " res = simul.run()\n", "\n", " data = [st.overlap(prep_state) for st in res.states]\n", @@ -483,7 +483,7 @@ " prep_state, prep_time = CCZ_sequence(state_id)\n", "\n", " # Construct Simulation instance\n", - " simul = Simulation(seq)\n", + " simul = QutipEmulator.from_sequence(seq)\n", "\n", " res = simul.run()\n", "\n", diff --git a/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb index 38fa8b4ae..1011f5752 100644 --- a/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb +++ b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb @@ -17,7 +17,7 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.devices import Chadoq2\n", "from pulser.waveforms import InterpolatedWaveform\n", "from scipy.optimize import minimize\n", @@ -263,7 +263,7 @@ " params = np.array(parameters)\n", " t_params, s_params = np.reshape(params.astype(int), (2, LAYERS))\n", " assigned_seq = seq.build(t_list=t_params, s_list=s_params)\n", - " simul = Simulation(assigned_seq, sampling_rate=0.01)\n", + " simul = QutipEmulator.from_sequence(assigned_seq, sampling_rate=0.01)\n", " results = simul.run()\n", " count_dict = results.sample_final_state() # sample from the state vector\n", " return count_dict" @@ -529,7 +529,7 @@ "metadata": {}, "outputs": [], "source": [ - "simul = Simulation(seq)\n", + "simul = QutipEmulator.from_sequence(seq)\n", "results = simul.run()\n", "final = results.get_final_state()\n", "count_dict = results.sample_final_state()" @@ -574,7 +574,7 @@ " 0,\n", " )\n", " seq.add(adiabatic_pulse, \"ising\")\n", - " simul = Simulation(seq)\n", + " simul = QutipEmulator.from_sequence(seq)\n", " results = simul.run()\n", " final = results.get_final_state()\n", " count_dict = results.sample_final_state()\n", diff --git a/tutorials/classical_simulation/Simulating Sequences with Errors and Noise.ipynb b/tutorials/classical_simulation/Simulating Sequences with Errors and Noise.ipynb index 30709b778..c70328a2c 100644 --- a/tutorials/classical_simulation/Simulating Sequences with Errors and Noise.ipynb +++ b/tutorials/classical_simulation/Simulating Sequences with Errors and Noise.ipynb @@ -48,7 +48,7 @@ "import qutip\n", "\n", "from pulser import Register, Pulse, Sequence\n", - "from pulser_simulation import SimConfig, Simulation\n", + "from pulser_simulation import SimConfig, QutipEmulator\n", "from pulser.devices import Chadoq2" ] }, @@ -131,7 +131,7 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq, sampling_rate=0.05)\n", + "sim = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "clean_res = sim.run()" ] }, @@ -399,7 +399,7 @@ } ], "source": [ - "sim = Simulation(\n", + "sim = QutipEmulator.from_sequence(\n", " seq,\n", " sampling_rate=0.05,\n", " config=SimConfig(\n", @@ -538,9 +538,9 @@ ], "metadata": { "kernelspec": { - "display_name": "pulser-dev", + "display_name": "pulserenv", "language": "python", - "name": "pulser-dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -552,12 +552,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.5" - }, - "vscode": { - "interpreter": { - "hash": "f824b8e4e16905929cafe3ad0d7552efed47de1342d6ced4330871a22269e77f" - } + "version": "3.11.3" } }, "nbformat": 4, diff --git a/tutorials/classical_simulation/Simulating with SPAM errors.ipynb b/tutorials/classical_simulation/Simulating with SPAM errors.ipynb index 7ca747f0a..8038c5142 100644 --- a/tutorials/classical_simulation/Simulating with SPAM errors.ipynb +++ b/tutorials/classical_simulation/Simulating with SPAM errors.ipynb @@ -32,7 +32,7 @@ "import qutip\n", "\n", "from pulser import Register, Pulse, Sequence\n", - "from pulser_simulation import SimConfig, Simulation\n", + "from pulser_simulation import SimConfig, QutipEmulator\n", "from pulser.devices import Chadoq2" ] }, @@ -102,7 +102,7 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq, sampling_rate=0.05)\n", + "sim = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "obs = qutip.basis(2, 0).proj()" ] }, diff --git a/tutorials/classical_simulation/Simulating with effective noise channels.ipynb b/tutorials/classical_simulation/Simulating with effective noise channels.ipynb index 6f3272c06..19e0edc61 100644 --- a/tutorials/classical_simulation/Simulating with effective noise channels.ipynb +++ b/tutorials/classical_simulation/Simulating with effective noise channels.ipynb @@ -44,7 +44,7 @@ "import matplotlib.pyplot as plt\n", "from copy import deepcopy\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation, SimConfig\n", + "from pulser_simulation import QutipEmulator, SimConfig\n", "from pulser_simulation.simresults import SimulationResults\n", "from pulser.devices import Chadoq2" ] @@ -15454,7 +15454,7 @@ "metadata": {}, "outputs": [], "source": [ - "clean_simu = Simulation(seq, sampling_rate=0.05)\n", + "clean_simu = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "clean_res = clean_simu.run()" ] }, @@ -15484,7 +15484,7 @@ "source": [ "obs = (\n", " clean_simu.initial_state.proj()\n", - ") # Make projector from initial state of the Simulation object (uses QuTiP)\n", + ") # Make projector from initial state of the QutipEmulator object (uses QuTiP)\n", "clean_res.plot(obs) # Plot the expectation value of the observable\n", "plt.ylabel(\"Ground state population\")\n", "plt.show()" @@ -15641,7 +15641,7 @@ "\n", " Args:\n", " psi: The state against which the population is measured.\n", - " noise_results: A list of SimulationResults.\n", + " noise_results: A list of QutipEmulator.from_sequenceResults.\n", " noise_probabilites: The noise probability associated with\n", " each element in 'noise_results'.\n", " decay_rate: The decay rate relative to the noise probability.\n", @@ -16026,7 +16026,7 @@ "metadata": {}, "outputs": [], "source": [ - "clean_simu = Simulation(seq, sampling_rate=0.05)\n", + "clean_simu = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "clean_res = clean_simu.run()" ] }, @@ -16286,7 +16286,7 @@ "pulse = Pulse.ConstantPulse(duration, 2 * np.pi, 0 * np.pi, 0.0)\n", "seq.add(pulse, \"channel 0\")\n", "\n", - "clean_simu = Simulation(seq, sampling_rate=0.05)\n", + "clean_simu = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "clean_res = clean_simu.run()" ] }, diff --git a/tutorials/classical_simulation/Simulating with laser noises.ipynb b/tutorials/classical_simulation/Simulating with laser noises.ipynb index e16627574..a27fbc7e9 100644 --- a/tutorials/classical_simulation/Simulating with laser noises.ipynb +++ b/tutorials/classical_simulation/Simulating with laser noises.ipynb @@ -32,7 +32,7 @@ "import qutip\n", "\n", "from pulser import Register, Pulse, Sequence\n", - "from pulser_simulation import SimConfig, Simulation\n", + "from pulser_simulation import SimConfig, QutipEmulator\n", "from pulser.devices import Chadoq2\n", "from pulser.waveforms import RampWaveform" ] @@ -114,7 +114,7 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq, sampling_rate=0.05)\n", + "sim = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", "sim.set_evaluation_times(0.4)\n", "res_clean = sim.run()\n", "obs = qutip.basis(2, 0).proj()\n", @@ -345,7 +345,7 @@ " laser_waist=100,\n", " temperature=100,\n", ")\n", - "simul = Simulation(\n", + "simul = QutipEmulator.from_sequence(\n", " seq,\n", " sampling_rate=0.05,\n", " evaluation_times=0.1,\n", diff --git a/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb b/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb index 45c66d3eb..f29133c5c 100644 --- a/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb +++ b/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb @@ -46,7 +46,7 @@ "from skopt import gp_minimize\n", "\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.waveforms import InterpolatedWaveform\n", "from pulser.devices import Chadoq2" ] @@ -249,7 +249,7 @@ "metadata": {}, "outputs": [], "source": [ - "simul = Simulation(seq)\n", + "simul = QutipEmulator.from_sequence(seq)\n", "results = simul.run()\n", "final = results.get_final_state()" ] @@ -403,7 +403,7 @@ " seq.declare_channel(\"ising\", \"rydberg_global\")\n", " seq.add(create_interp_pulse(params[:m], params[m:]), \"ising\")\n", "\n", - " simul = Simulation(seq, sampling_rate=0.5)\n", + " simul = QutipEmulator.from_sequence(seq, sampling_rate=0.5)\n", " results = simul.run()\n", "\n", " sampling = results.sample_final_state(N_samples=N_samples)\n", @@ -666,7 +666,7 @@ } ], "source": [ - "simul = Simulation(seq)\n", + "simul = QutipEmulator.from_sequence(seq)\n", "results = simul.run()\n", "print(\"final =\", proba_from_state(results, min_p=0.05))\n", "\n", diff --git a/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb b/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb index 9c61f3868..1aab5f2c1 100644 --- a/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb +++ b/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb @@ -25,7 +25,7 @@ "import qutip\n", "\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.waveforms import CompositeWaveform, RampWaveform, ConstantWaveform\n", "from pulser.devices import MockDevice" ] @@ -474,7 +474,7 @@ } ], "source": [ - "simul = Simulation(seq, sampling_rate=0.1)\n", + "simul = QutipEmulator.from_sequence(seq, sampling_rate=0.1)\n", "\n", "occup_list = [occupation(reg, j) for j in range(len(reg.qubits))]\n", "\n", @@ -699,7 +699,7 @@ "\n", "phase_diagram(seq)\n", "\n", - "simul = Simulation(seq, sampling_rate=0.1)\n", + "simul = QutipEmulator.from_sequence(seq, sampling_rate=0.1)\n", "\n", "occup_list = [occupation(reg, j) for j in range(simul._size)]\n", "\n", @@ -823,7 +823,7 @@ "\n", "phase_diagram(seq)\n", "\n", - "simul = Simulation(seq, sampling_rate=0.2)\n", + "simul = QutipEmulator.from_sequence(seq, sampling_rate=0.2)\n", "\n", "occup_list = [occupation(reg, j) for j in range(len(reg.qubits))]\n", "\n", diff --git a/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb b/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb index 40d7b134e..fb35faf5b 100644 --- a/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb +++ b/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb @@ -20,7 +20,7 @@ "\n", "import pulser\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.devices import MockDevice, Chadoq2\n", "from pulser.waveforms import BlackmanWaveform" ] @@ -159,7 +159,9 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq, sampling_rate=1.0, config=None, evaluation_times=t_list)\n", + "sim = QutipEmulator.from_sequence(\n", + " seq, sampling_rate=1.0, config=None, evaluation_times=t_list\n", + ")\n", "psi_y = (qutip.basis(2, 0) + 1j * qutip.basis(2, 1)).unit()\n", "sim.set_initial_state(qutip.tensor(psi_y, psi_y))\n", "res = sim.run()" @@ -297,7 +299,7 @@ " seq.add(X_pulse, \"MW\")\n", " Floquet_XX2Z_cycles(m, t_pulse)\n", " seq.add(mX_pulse, \"MW\")\n", - " sim = Simulation(seq)\n", + " sim = QutipEmulator.from_sequence(seq)\n", " res = sim.run()\n", " samples = res.sample_final_state(N_samples)\n", " correl = 0.0\n", @@ -349,7 +351,7 @@ " seq.add(X_pulse, \"MW\")\n", " Floquet_XX2Z_cycles(m, t_pulse)\n", " seq.add(mX_pulse, \"MW\")\n", - " sim = Simulation(seq)\n", + " sim = QutipEmulator.from_sequence(seq)\n", " res = sim.run()\n", " samples = res.sample_final_state(N_samples)\n", " correl = 0.0\n", @@ -496,7 +498,7 @@ " seq.add(X_pulse, \"MW\")\n", " Floquet_XX2Z_cycles(m, t_pulse)\n", " seq.add(mX_pulse, \"MW\")\n", - " sim = Simulation(seq)\n", + " sim = QutipEmulator.from_sequence(seq)\n", " res = sim.run()\n", " samples = res.sample_final_state(N_samples)\n", " samples_evol.append(samples)\n", diff --git a/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb b/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb index e09fa891d..f344347b6 100644 --- a/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb +++ b/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb @@ -22,7 +22,7 @@ "import qutip\n", "\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.waveforms import RampWaveform\n", "from pulser.devices import Chadoq2" ] @@ -195,7 +195,7 @@ "metadata": {}, "outputs": [], "source": [ - "simul = Simulation(seq, sampling_rate=0.02)\n", + "simul = QutipEmulator.from_sequence(seq, sampling_rate=0.02)\n", "results = simul.run(progress_bar=True)" ] }, @@ -488,7 +488,7 @@ " seq.add(sweep, \"ising\")\n", " seq.add(fall, \"ising\")\n", "\n", - " simul = Simulation(seq, sampling_rate=0.2)\n", + " simul = QutipEmulator.from_sequence(seq, sampling_rate=0.2)\n", " results = simul.run()\n", "\n", " final = results.states[-1]\n", diff --git a/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb b/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb index e9838f400..bc9258ce5 100644 --- a/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb +++ b/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb @@ -103,7 +103,7 @@ "\n", "from pulser import Register, Sequence, Pulse\n", "from pulser.devices import Chadoq2\n", - "from pulser_simulation import Simulation" + "from pulser_simulation import QutipEmulator" ] }, { @@ -907,7 +907,7 @@ " seq.add(pulse_2, \"ch0\")\n", "\n", " seq.measure(\"ground-rydberg\")\n", - " simul = Simulation(seq, sampling_rate=0.05)\n", + " simul = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", " simul.set_initial_state(in_state)\n", " results = simul.run()\n", " return results.expect([H])[-1][-1]\n", @@ -1088,7 +1088,7 @@ " seq.add(pulse_2, \"ch0\")\n", "\n", " seq.measure(\"ground-rydberg\")\n", - " simul = Simulation(seq, sampling_rate=0.01)\n", + " simul = QutipEmulator.from_sequence(seq, sampling_rate=0.01)\n", " simul.set_initial_state(in_state)\n", "\n", " # Classical shadow estimation\n", @@ -1307,7 +1307,7 @@ " seq.add(pulse_2, \"ch0\")\n", "\n", " seq.measure(\"ground-rydberg\")\n", - " simul = Simulation(seq, sampling_rate=0.05)\n", + " simul = QutipEmulator.from_sequence(seq, sampling_rate=0.05)\n", " simul.set_initial_state(in_state)\n", "\n", " # Classical shadow estimation\n", diff --git a/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb b/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb index ef5d59163..76df69e12 100644 --- a/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb +++ b/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb @@ -43,7 +43,7 @@ "\n", "import pulser\n", "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.devices import MockDevice\n", "from pulser.waveforms import BlackmanWaveform" ] @@ -93,7 +93,7 @@ "seq.add(simple_pulse, \"MW\")\n", "seq.measure(basis=\"XY\")\n", "\n", - "sim = Simulation(seq)\n", + "sim = QutipEmulator.from_sequence(seq)\n", "\n", "results = sim.run(progress_bar=True, nsteps=5000)" ] @@ -164,7 +164,7 @@ "seq.add(simple_pulse, \"ch0\")\n", "seq.measure(basis=\"XY\")\n", "\n", - "sim = Simulation(seq, sampling_rate=1)\n", + "sim = QutipEmulator.from_sequence(seq, sampling_rate=1)\n", "results = sim.run(nsteps=5000)" ] }, @@ -268,7 +268,7 @@ "seq.add(simple_pulse, \"ch0\")\n", "seq.measure(basis=\"XY\")\n", "\n", - "sim = Simulation(seq, sampling_rate=1)\n", + "sim = QutipEmulator.from_sequence(seq, sampling_rate=1)\n", "results = sim.run(progress_bar=True, nsteps=5000)" ] }, diff --git a/tutorials/simulating_sequences.ipynb b/tutorials/simulating_sequences.ipynb index b5ec37781..09583d5a3 100644 --- a/tutorials/simulating_sequences.ipynb +++ b/tutorials/simulating_sequences.ipynb @@ -14,7 +14,7 @@ "outputs": [], "source": [ "from pulser import Pulse, Sequence, Register\n", - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "from pulser.waveforms import BlackmanWaveform, RampWaveform\n", "from pulser.devices import MockDevice\n", "\n", @@ -118,7 +118,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "First we define our `Simulation` object, which creates an internal respresentation of the quantum system, including the Hamiltonian which will drive the evolution:" + "First we define our `QutipEmulator` object, which creates an internal respresentation of the quantum system, including the Hamiltonian which will drive the evolution:" ] }, { @@ -127,7 +127,7 @@ "metadata": {}, "outputs": [], "source": [ - "sim = Simulation(seq, sampling_rate=0.1)" + "sim = QutipEmulator.from_sequence(seq, sampling_rate=0.1)" ] }, { From 2ed29566ffc5beb24a2e41b679fc79828e3ee680 Mon Sep 17 00:00:00 2001 From: Antoine Cornillot <61453516+a-corni@users.noreply.github.com> Date: Fri, 7 Jul 2023 17:40:21 +0200 Subject: [PATCH 12/19] Upgrade qutip to drop restriction on scipy (#549) * Upgrade qutip, drop restriction on scipy * validating sequence and device serialization * Revert "validating sequence and device serialization" This reverts commit a7e09171c5d9c5c50790e3da16783ceeec50e139. --- pulser-core/requirements.txt | 2 +- pulser-simulation/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pulser-core/requirements.txt b/pulser-core/requirements.txt index 019c2b781..40cee3bfc 100644 --- a/pulser-core/requirements.txt +++ b/pulser-core/requirements.txt @@ -2,4 +2,4 @@ jsonschema < 4.18 matplotlib # Numpy 1.20 introduces type hints, 1.24.0 breaks matplotlib < 3.6.1 numpy >= 1.20, != 1.24.0 -scipy < 1.11 # TODO: Remove once it is supported by qutip +scipy \ No newline at end of file diff --git a/pulser-simulation/requirements.txt b/pulser-simulation/requirements.txt index 59c46d8fc..f40b1a0fe 100644 --- a/pulser-simulation/requirements.txt +++ b/pulser-simulation/requirements.txt @@ -1 +1 @@ -qutip>=4.7.1 +qutip~=4.7.2 From 031523ba2180973facacd236650d042a55f82d0f Mon Sep 17 00:00:00 2001 From: Antoine Cornillot <61453516+a-corni@users.noreply.github.com> Date: Tue, 11 Jul 2023 16:02:33 +0200 Subject: [PATCH 13/19] Validate device, seq against the abstract representation schema in serialization (#550) * Adding validation of device, seq in serialization * sorting imports * Introducing init file with variables * Taking into account review comments --- pulser-core/pulser/devices/_device_datacls.py | 5 ++- .../pulser/json/abstract_repr/__init__.py | 8 ++++ .../pulser/json/abstract_repr/deserializer.py | 26 +++--------- .../pulser/json/abstract_repr/serializer.py | 7 +++- .../pulser/json/abstract_repr/validation.py | 41 +++++++++++++++++++ tests/test_abstract_repr.py | 4 +- 6 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 pulser-core/pulser/json/abstract_repr/validation.py diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index 247394fb3..671bf780e 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -26,6 +26,7 @@ from pulser.channels.base_channel import Channel from pulser.devices.interaction_coefficients import c6_dict from pulser.json.abstract_repr.serializer import AbstractReprEncoder +from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.utils import obj_to_dict from pulser.register.base_register import BaseRegister, QubitId from pulser.register.mappable_reg import MappableRegister @@ -417,7 +418,9 @@ def _to_abstract_repr(self) -> dict[str, Any]: def to_abstract_repr(self) -> str: """Serializes the Sequence into an abstract JSON object.""" - return json.dumps(self, cls=AbstractReprEncoder) + abstr_dev_str = json.dumps(self, cls=AbstractReprEncoder) + validate_abstract_repr(abstr_dev_str, "device") + return abstr_dev_str @dataclass(frozen=True, repr=False) diff --git a/pulser-core/pulser/json/abstract_repr/__init__.py b/pulser-core/pulser/json/abstract_repr/__init__.py index 9698aced3..040db5803 100644 --- a/pulser-core/pulser/json/abstract_repr/__init__.py +++ b/pulser-core/pulser/json/abstract_repr/__init__.py @@ -12,3 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. """Serialization and deserialization tools for the abstract representation.""" +import json +from pathlib import Path + +SCHEMAS_PATH = Path(__file__).parent / "schemas" +SCHEMAS = {} +for obj_type in ("device", "sequence"): + with open(SCHEMAS_PATH / f"{obj_type}-schema.json") as f: + SCHEMAS[obj_type] = json.load(f) diff --git a/pulser-core/pulser/json/abstract_repr/deserializer.py b/pulser-core/pulser/json/abstract_repr/deserializer.py index 2b8d2dda6..e56d90ba9 100644 --- a/pulser-core/pulser/json/abstract_repr/deserializer.py +++ b/pulser-core/pulser/json/abstract_repr/deserializer.py @@ -16,7 +16,6 @@ import dataclasses import json -from pathlib import Path from typing import TYPE_CHECKING, Any, Type, Union, cast, overload import jsonschema @@ -32,6 +31,7 @@ BINARY_OPERATORS, UNARY_OPERATORS, ) +from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.exceptions import AbstractReprError, DeserializeDeviceError from pulser.parametrized import ParamObj, Variable from pulser.pulse import Pulse @@ -58,17 +58,6 @@ ExpReturnType = Union[int, float, ParamObj] -schemas_path = Path(__file__).parent / "schemas" -schemas = {} -for obj_type in ("device", "sequence"): - with open(schemas_path / f"{obj_type}-schema.json") as f: - schemas[obj_type] = json.load(f) - -resolver = jsonschema.validators.RefResolver( - base_uri=f"{schemas_path.resolve().as_uri()}/", - referrer=schemas["sequence"], -) - @overload def _deserialize_parameter(param: int, vars: dict[str, Variable]) -> int: @@ -370,13 +359,9 @@ def deserialize_abstract_sequence(obj_str: str) -> Sequence: Returns: Sequence: The Pulser sequence. """ - obj = json.loads(obj_str) - # Validate the format of the data against the JSON schema. - jsonschema.validate( - instance=obj, schema=schemas["sequence"], resolver=resolver - ) - + validate_abstract_repr(obj_str, "sequence") + obj = json.loads(obj_str) # Device if isinstance(obj["device"], str): device_name = obj["device"] @@ -462,10 +447,9 @@ def deserialize_device(obj_str: str) -> Device | VirtualDevice: raise DeserializeDeviceError from type_error try: - obj = json.loads(obj_str) # Validate the format of the data against the JSON schema. - jsonschema.validate(instance=obj, schema=schemas["device"]) - return _deserialize_device_object(obj) + validate_abstract_repr(obj_str, "device") + return _deserialize_device_object(json.loads(obj_str)) except ( json.JSONDecodeError, # From json.loads jsonschema.exceptions.ValidationError, # From jsonschema.validate diff --git a/pulser-core/pulser/json/abstract_repr/serializer.py b/pulser-core/pulser/json/abstract_repr/serializer.py index a830599ee..5c2c0a3f5 100644 --- a/pulser-core/pulser/json/abstract_repr/serializer.py +++ b/pulser-core/pulser/json/abstract_repr/serializer.py @@ -24,6 +24,7 @@ import numpy as np from pulser.json.abstract_repr.signatures import SIGNATURES +from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.exceptions import AbstractReprError from pulser.register.base_register import QubitId @@ -292,4 +293,8 @@ def get_all_args( else: raise AbstractReprError(f"Unknown call '{call.name}'.") - return json.dumps(res, cls=AbstractReprEncoder, **json_dumps_options) + abstr_seq_str = json.dumps( + res, cls=AbstractReprEncoder, **json_dumps_options + ) + validate_abstract_repr(abstr_seq_str, "sequence") + return abstr_seq_str diff --git a/pulser-core/pulser/json/abstract_repr/validation.py b/pulser-core/pulser/json/abstract_repr/validation.py new file mode 100644 index 000000000..09bd7f062 --- /dev/null +++ b/pulser-core/pulser/json/abstract_repr/validation.py @@ -0,0 +1,41 @@ +# Copyright 2022 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Function for validation of JSON serialization to abstract representation.""" +import json +from typing import Literal + +import jsonschema + +from pulser.json.abstract_repr import SCHEMAS, SCHEMAS_PATH + +RESOLVER = jsonschema.validators.RefResolver( + base_uri=f"{SCHEMAS_PATH.resolve().as_uri()}/", + referrer=SCHEMAS["sequence"], +) + + +def validate_abstract_repr( + obj_str: str, name: Literal["sequence", "device"] +) -> None: + """Validate the abstract representation of an object. + + Args: + obj_str: A JSON-formatted string encoding the object. + name: The type of object to validate (can be "sequence" or "device"). + """ + obj = json.loads(obj_str) + validate_args = dict(instance=obj, schema=SCHEMAS[name]) + if name == "sequence": + validate_args["resolver"] = RESOLVER + jsonschema.validate(**validate_args) diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index c6dd5da8c..9d820cad9 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -29,12 +29,12 @@ from pulser.json.abstract_repr.deserializer import ( VARIABLE_TYPE_MAP, deserialize_device, - resolver, ) from pulser.json.abstract_repr.serializer import ( AbstractReprEncoder, abstract_repr, ) +from pulser.json.abstract_repr.validation import RESOLVER from pulser.json.exceptions import AbstractReprError, DeserializeDeviceError from pulser.parametrized.decorators import parametrize from pulser.parametrized.paramobj import ParamObj @@ -193,7 +193,7 @@ def validate_schema(instance): "pulser-core/pulser/json/abstract_repr/schemas/" "sequence-schema.json" ) as f: schema = json.load(f) - jsonschema.validate(instance=instance, schema=schema, resolver=resolver) + jsonschema.validate(instance=instance, schema=schema, resolver=RESOLVER) class TestSerialization: From 87ee5be82a36731bb47e3791df7c18869d86b676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Thu, 13 Jul 2023 14:02:25 +0200 Subject: [PATCH 14/19] Updates to the backend interfaces (#541) * Upgrade pasqal-cloud and add convenience imports * Make job_params required + Recover jobs order * Implement PasqalCloud.fetch_available_devices() * Updates for new pasqal-cloud version * Type hinting * Finish UTs * Adding convenience imports * Adding tutorial on backends * Import sorting and formating * Update API referencee * Fix UTs * Update pasqal-cloud version * Avoiding mutable defaults * Use backoff on cloud interactions * Add link to the API reference in the tutorial * Fix typo in tutorial --- docs/source/apidoc/backend.rst | 32 ++ docs/source/apidoc/cloud.rst | 9 - docs/source/apidoc/pulser.rst | 2 +- docs/source/index.rst | 6 + docs/source/tutorials/backends.nblink | 3 + pulser-core/pulser/__init__.py | 2 + pulser-core/pulser/backend/__init__.py | 4 + pulser-core/pulser/backend/config.py | 4 +- pulser-core/pulser/backend/noise_model.py | 2 +- pulser-core/pulser/backend/qpu.py | 2 +- pulser-core/pulser/backend/remote.py | 22 +- pulser-pasqal/pulser_pasqal/__init__.py | 1 + pulser-pasqal/pulser_pasqal/backends.py | 39 +- pulser-pasqal/pulser_pasqal/pasqal_cloud.py | 99 +++- pulser-pasqal/requirements.txt | 4 +- .../pulser_simulation/__init__.py | 1 + tests/test_backend.py | 4 +- tests/test_pasqal.py | 110 +++-- .../Backends for Sequence Execution.ipynb | 457 ++++++++++++++++++ 19 files changed, 704 insertions(+), 99 deletions(-) create mode 100644 docs/source/apidoc/backend.rst delete mode 100644 docs/source/apidoc/cloud.rst create mode 100644 docs/source/tutorials/backends.nblink create mode 100644 tutorials/advanced_features/Backends for Sequence Execution.ipynb diff --git a/docs/source/apidoc/backend.rst b/docs/source/apidoc/backend.rst new file mode 100644 index 000000000..263b35ce2 --- /dev/null +++ b/docs/source/apidoc/backend.rst @@ -0,0 +1,32 @@ +************************ +Backend Interfaces +************************ + +QPU +---- + +.. autoclass:: pulser.QPUBackend + :members: + + +Emulators +---------- + +Local +^^^^^^^ +.. autoclass:: pulser_simulation.QutipBackend + :members: + +Remote +^^^^^^^^^^ +.. autoclass:: pulser_pasqal.EmuTNBackend + :members: + +.. autoclass:: pulser_pasqal.EmuFreeBackend + :members: + + +Remote backend connection +--------------------------- + +.. autoclass:: pulser_pasqal.PasqalCloud diff --git a/docs/source/apidoc/cloud.rst b/docs/source/apidoc/cloud.rst deleted file mode 100644 index af977455e..000000000 --- a/docs/source/apidoc/cloud.rst +++ /dev/null @@ -1,9 +0,0 @@ -************************ -Pasqal Cloud connection -************************ - -PasqalCloud ----------------------- - -.. autoclass:: pulser_pasqal.PasqalCloud - :members: diff --git a/docs/source/apidoc/pulser.rst b/docs/source/apidoc/pulser.rst index 4ac1d617f..82533cce0 100644 --- a/docs/source/apidoc/pulser.rst +++ b/docs/source/apidoc/pulser.rst @@ -6,4 +6,4 @@ API Reference core simulation - cloud + backend diff --git a/docs/source/index.rst b/docs/source/index.rst index 955f57596..6e953fa98 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -61,6 +61,12 @@ computers and simulators, check the pages in :doc:`review`. review +.. toctree:: + :maxdepth: 2 + :caption: Backend Execution + + tutorials/backends + .. toctree:: :maxdepth: 2 :caption: Classical Simulation diff --git a/docs/source/tutorials/backends.nblink b/docs/source/tutorials/backends.nblink new file mode 100644 index 000000000..02acdbc43 --- /dev/null +++ b/docs/source/tutorials/backends.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../tutorials/advanced_features/Backends for Sequence Execution.ipynb" +} \ No newline at end of file diff --git a/pulser-core/pulser/__init__.py b/pulser-core/pulser/__init__.py index 128bde91a..9dccecc5b 100644 --- a/pulser-core/pulser/__init__.py +++ b/pulser-core/pulser/__init__.py @@ -18,3 +18,5 @@ from pulser.pulse import Pulse from pulser.register import Register, Register3D from pulser.sequence import Sequence + +from pulser.backend import QPUBackend # isort: skip diff --git a/pulser-core/pulser/backend/__init__.py b/pulser-core/pulser/backend/__init__.py index ac8e7e552..65c4f1ff5 100644 --- a/pulser-core/pulser/backend/__init__.py +++ b/pulser-core/pulser/backend/__init__.py @@ -12,3 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Classes for backend execution.""" + +from pulser.backend.config import EmulatorConfig +from pulser.backend.noise_model import NoiseModel +from pulser.backend.qpu import QPUBackend diff --git a/pulser-core/pulser/backend/config.py b/pulser-core/pulser/backend/config.py index eb7f27cfb..6a30f2862 100644 --- a/pulser-core/pulser/backend/config.py +++ b/pulser-core/pulser/backend/config.py @@ -24,7 +24,7 @@ EVAL_TIMES_LITERAL = Literal["Full", "Minimal", "Final"] -@dataclass +@dataclass(frozen=True) class BackendConfig: """The base backend configuration. @@ -35,7 +35,7 @@ class BackendConfig: backend_options: dict[str, Any] = field(default_factory=dict) -@dataclass +@dataclass(frozen=True) class EmulatorConfig(BackendConfig): """The configuration for emulator backends. diff --git a/pulser-core/pulser/backend/noise_model.py b/pulser-core/pulser/backend/noise_model.py index ca862394a..98b376354 100644 --- a/pulser-core/pulser/backend/noise_model.py +++ b/pulser-core/pulser/backend/noise_model.py @@ -24,7 +24,7 @@ ] -@dataclass +@dataclass(frozen=True) class NoiseModel: """Specifies the noise model parameters for emulation. diff --git a/pulser-core/pulser/backend/qpu.py b/pulser-core/pulser/backend/qpu.py index f2a141d3f..ef3d11751 100644 --- a/pulser-core/pulser/backend/qpu.py +++ b/pulser-core/pulser/backend/qpu.py @@ -24,7 +24,7 @@ class QPUBackend(RemoteBackend): """Backend for sequence execution on a QPU.""" - def run(self, job_params: list[JobParams] = []) -> RemoteResults: + def run(self, job_params: list[JobParams] | None = None) -> RemoteResults: """Runs the sequence on the remote QPU and returns the result. Args: diff --git a/pulser-core/pulser/backend/remote.py b/pulser-core/pulser/backend/remote.py index 0741dcf49..39cbc5044 100644 --- a/pulser-core/pulser/backend/remote.py +++ b/pulser-core/pulser/backend/remote.py @@ -58,12 +58,20 @@ class RemoteResults(Results): the results. connection: The remote connection over which to get the submission's status and fetch the results. + jobs_order: An optional list of job IDs (as stored by the connection) + used to order the results. """ - def __init__(self, submission_id: str, connection: RemoteConnection): + def __init__( + self, + submission_id: str, + connection: RemoteConnection, + jobs_order: list[str] | None = None, + ): """Instantiates a new collection of remote results.""" self._submission_id = submission_id self._connection = connection + self._jobs_order = jobs_order @property def results(self) -> tuple[Result, ...]: @@ -79,7 +87,9 @@ def __getattr__(self, name: str) -> Any: status = self.get_status() if status == SubmissionStatus.DONE: self._results = tuple( - self._connection._fetch_result(self._submission_id) + self._connection._fetch_result( + self._submission_id, self._jobs_order + ) ) return self._results raise RemoteResultsError( @@ -102,7 +112,9 @@ def submit( pass @abstractmethod - def _fetch_result(self, submission_id: str) -> typing.Sequence[Result]: + def _fetch_result( + self, submission_id: str, jobs_order: list[str] | None + ) -> typing.Sequence[Result]: """Fetches the results of a completed submission.""" pass @@ -116,8 +128,8 @@ def _get_submission_status(self, submission_id: str) -> SubmissionStatus: pass def fetch_available_devices(self) -> dict[str, Device]: - """Fetches the available devices through this connection.""" - raise NotImplementedError( + """Fetches the devices available through this connection.""" + raise NotImplementedError( # pragma: no cover "Unable to fetch the available devices through this " "remote connection." ) diff --git a/pulser-pasqal/pulser_pasqal/__init__.py b/pulser-pasqal/pulser_pasqal/__init__.py index 793a9ceac..6f9c57bf3 100644 --- a/pulser-pasqal/pulser_pasqal/__init__.py +++ b/pulser-pasqal/pulser_pasqal/__init__.py @@ -16,5 +16,6 @@ from pasqal_cloud import BaseConfig, EmulatorType, Endpoints from pulser_pasqal._version import __version__ +from pulser_pasqal.backends import EmuFreeBackend, EmuTNBackend from pulser_pasqal.job_parameters import JobParameters, JobVariables from pulser_pasqal.pasqal_cloud import PasqalCloud diff --git a/pulser-pasqal/pulser_pasqal/backends.py b/pulser-pasqal/pulser_pasqal/backends.py index 4dbf68456..56dfb0b15 100644 --- a/pulser-pasqal/pulser_pasqal/backends.py +++ b/pulser-pasqal/pulser_pasqal/backends.py @@ -24,9 +24,7 @@ from pulser.backend.remote import JobParams, RemoteBackend, RemoteResults from pulser_pasqal.pasqal_cloud import PasqalCloud -DEFAULT_CONFIG_EMU_TN = EmulatorConfig( - evaluation_times="Final", sampling_rate=0.1 -) +DEFAULT_CONFIG_EMU_TN = EmulatorConfig(evaluation_times="Final") DEFAULT_CONFIG_EMU_FREE = EmulatorConfig( evaluation_times="Final", sampling_rate=0.25 ) @@ -62,29 +60,24 @@ def run( """Executes on the emulator backend through the Pasqal Cloud. Args: - job_params: An optional list of parameters for each job to execute. - Must be provided only when the sequence is parametrized as - a list of mappings, where each mapping contains one mapping - of variable names to values under the 'variables' field. + job_params: A list of parameters for each job to execute. Each + mapping must contain a defined 'runs' field specifying + the number of times to run the same sequence. If the sequence + is parametrized, the values for all the variables necessary + to build the sequence must be given in it's own mapping, for + each job, under the 'variables' field. Returns: The results, which can be accessed once all sequences have been successfully executed. """ - needs_build = ( - self._sequence.is_parametrized() - or self._sequence.is_register_mappable() - ) - if job_params is None and needs_build: - raise ValueError( - "When running a sequence that requires building, " - "'job_params' must be provided." - ) - elif job_params and not needs_build: + suffix = f" when executing a sequence on {self.__class__.__name__}." + if not job_params: + raise ValueError("'job_params' must be specified" + suffix) + if any("runs" not in j for j in job_params): raise ValueError( - "'job_params' cannot be provided when running built " - "sequences on an emulator backend." + "All elements of 'job_params' must specify 'runs'" + suffix ) return self._connection.submit( @@ -119,9 +112,9 @@ class EmuTNBackend(PasqalEmulator): - sampling_rate - backend_options: - precision (str): The precision of the simulation. Can be "low", - "normal" or "high". Defaults to "normal". + "normal" or "high". Defaults to "normal". - max_bond_dim (int): The maximum bond dimension of the Matrix - Product State (MPS). Defaults to 500. + Product State (MPS). Defaults to 500. All other parameters should not be changed from their default values. @@ -142,8 +135,8 @@ class EmuFreeBackend(PasqalEmulator): Configurable fields in EmulatorConfig: - backend_options: - - with_noise (bool): Whether to add noise to the simulation. - Defaults to False. + - with_noise (bool): Whether to add noise to the simulation. + Defaults to False. All other parameters should not be changed from their default values. diff --git a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py index 95178f010..1173e472d 100644 --- a/pulser-pasqal/pulser_pasqal/pasqal_cloud.py +++ b/pulser-pasqal/pulser_pasqal/pasqal_cloud.py @@ -15,10 +15,13 @@ from __future__ import annotations import copy +import json import warnings from dataclasses import fields -from typing import Any, Optional, Type +from typing import Any, Optional, Type, cast +import backoff +import numpy as np import pasqal_cloud from pasqal_cloud.device.configuration import ( BaseConfig, @@ -35,6 +38,7 @@ SubmissionStatus, ) from pulser.devices import Device +from pulser.json.abstract_repr.deserializer import deserialize_device from pulser.result import Result, SampledResult from pulser_pasqal.job_parameters import JobParameters @@ -43,6 +47,29 @@ pasqal_cloud.EmulatorType.EMU_TN: EmuTNConfig, } +MAX_CLOUD_ATTEMPTS = 5 + +backoff_decorator = backoff.on_exception( + backoff.fibo, Exception, max_tries=MAX_CLOUD_ATTEMPTS, max_value=60 +) + + +def _make_json_compatible(obj: Any) -> Any: + """Makes an object compatible with JSON serialization. + + For now, simply converts Numpy arrays to lists, but more can be added + as needed. + """ + + class NumpyEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: + if isinstance(o, np.ndarray): + return o.tolist() + return json.JSONEncoder.default(self, o) + + # Serializes with the custom encoder and then deserializes back + return json.loads(json.dumps(obj, cls=NumpyEncoder)) + class PasqalCloud(RemoteConnection): """Manager of the connection to PASQAL's cloud platform. @@ -51,9 +78,9 @@ class PasqalCloud(RemoteConnection): QPUs. Args: - username: your username in the PASQAL cloud platform. - password: the password for your PASQAL cloud platform account. - group_id: the group_id associated to the account. + username: Your username in the PASQAL cloud platform. + password: The password for your PASQAL cloud platform account. + project_id: The project ID associated to the account. kwargs: Additional arguments to provide to the pasqal_cloud.SDK() """ @@ -61,14 +88,15 @@ def __init__( self, username: str = "", password: str = "", - group_id: str = "", + project_id: str = "", **kwargs: Any, ): """Initializes a connection to the Pasqal cloud platform.""" + project_id_ = project_id or kwargs.pop("group_id", "") self._sdk_connection = pasqal_cloud.SDK( username=username, password=password, - group_id=group_id, + project_id=project_id_, **kwargs, ) @@ -86,7 +114,9 @@ def submit(self, sequence: Sequence, **kwargs: Any) -> RemoteResults: sequence.measure(bases[0]) emulator = kwargs.get("emulator", None) - job_params: list[JobParams] = kwargs.get("job_params", []) + job_params: list[JobParams] = _make_json_compatible( + kwargs.get("job_params", []) + ) if emulator is None: available_devices = self.fetch_available_devices() # TODO: Could be better to check if the devices are @@ -97,6 +127,7 @@ def submit(self, sequence: Sequence, **kwargs: Any) -> RemoteResults: "of the devices currently available through the remote " "connection." ) + # TODO: Validate the register layout if sequence.is_parametrized() or sequence.is_register_mappable(): for params in job_params: @@ -106,29 +137,60 @@ def submit(self, sequence: Sequence, **kwargs: Any) -> RemoteResults: configuration = self._convert_configuration( config=kwargs.get("config", None), emulator=emulator ) - - batch = self._sdk_connection.create_batch( + create_batch_fn = backoff_decorator(self._sdk_connection.create_batch) + batch = create_batch_fn( serialized_sequence=sequence.to_abstract_repr(), jobs=job_params or [], # type: ignore[arg-type] emulator=emulator, configuration=configuration, wait=False, - fetch_results=False, ) - return RemoteResults(batch.id, self) + jobs_order = [] + if job_params: + for job_dict in job_params: + for job in batch.jobs.values(): + if ( + job.id not in jobs_order + and job_dict["runs"] == job.runs + and job_dict.get("variables", None) == job.variables + ): + jobs_order.append(job.id) + break + else: + raise RuntimeError( + f"Failed to find job ID for {job_dict}." + ) + + return RemoteResults(batch.id, self, jobs_order or None) + + @backoff_decorator + def fetch_available_devices(self) -> dict[str, Device]: + """Fetches the devices available through this connection.""" + abstract_devices = self._sdk_connection.get_device_specs_dict() + return { + name: cast(Device, deserialize_device(dev_str)) + for name, dev_str in abstract_devices.items() + } - def _fetch_result(self, submission_id: str) -> tuple[Result, ...]: + def _fetch_result( + self, submission_id: str, jobs_order: list[str] | None + ) -> tuple[Result, ...]: # For now, the results are always sampled results - batch = self._sdk_connection.get_batch( - id=submission_id, fetch_results=True - ) + get_batch_fn = backoff_decorator(self._sdk_connection.get_batch) + batch = get_batch_fn(id=submission_id) seq_builder = Sequence.from_abstract_repr(batch.sequence_builder) reg = seq_builder.get_register(include_mappable=True) all_qubit_ids = reg.qubit_ids meas_basis = seq_builder.get_measurement_basis() results = [] - for job in batch.jobs.values(): + + jobs = ( + (batch.jobs[job_id] for job_id in jobs_order) + if jobs_order + else batch.jobs.values() + ) + for job in jobs: vars = job.variables size: int | None = None if vars and "qubits" in vars: @@ -143,11 +205,10 @@ def _fetch_result(self, submission_id: str) -> tuple[Result, ...]: ) return tuple(results) + @backoff_decorator def _get_submission_status(self, submission_id: str) -> SubmissionStatus: """Gets the status of a submission from its ID.""" - batch = self._sdk_connection.get_batch( - id=submission_id, fetch_results=False - ) + batch = self._sdk_connection.get_batch(id=submission_id) return SubmissionStatus[batch.status] def _convert_configuration( diff --git a/pulser-pasqal/requirements.txt b/pulser-pasqal/requirements.txt index f636822df..067102a0b 100644 --- a/pulser-pasqal/requirements.txt +++ b/pulser-pasqal/requirements.txt @@ -1,2 +1,2 @@ -pasqal-cloud ~= 0.2.3 -pydantic < 2.0 +pasqal-cloud ~= 0.3.3 +backoff ~= 2.2 \ No newline at end of file diff --git a/pulser-simulation/pulser_simulation/__init__.py b/pulser-simulation/pulser_simulation/__init__.py index d35b82d4b..9964e23ff 100644 --- a/pulser-simulation/pulser_simulation/__init__.py +++ b/pulser-simulation/pulser_simulation/__init__.py @@ -14,5 +14,6 @@ """Classes for classical emulation of a Sequence.""" from pulser_simulation._version import __version__ +from pulser_simulation.qutip_backend import QutipBackend from pulser_simulation.simconfig import SimConfig from pulser_simulation.simulation import QutipEmulator, Simulation diff --git a/tests/test_backend.py b/tests/test_backend.py index aa4fab5bb..1fb282e87 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -220,7 +220,9 @@ def __init__(self): def submit(self, sequence, **kwargs) -> RemoteResults: return RemoteResults("abcd", self) - def _fetch_result(self, submission_id: str) -> typing.Sequence[Result]: + def _fetch_result( + self, submission_id: str, jobs_order: list[str] | None + ) -> typing.Sequence[Result]: return ( SampledResult( ("q0", "q1"), diff --git a/tests/test_pasqal.py b/tests/test_pasqal.py index d80e59017..ae670acf4 100644 --- a/tests/test_pasqal.py +++ b/tests/test_pasqal.py @@ -13,11 +13,13 @@ # limitations under the License. from __future__ import annotations +import copy import dataclasses from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +import numpy as np import pytest from pasqal_cloud.device.configuration import EmuFreeConfig, EmuTNConfig @@ -31,6 +33,7 @@ ) from pulser.devices import Chadoq2 from pulser.register import Register +from pulser.register.special_layouts import SquareLatticeLayout from pulser.result import SampledResult from pulser.sequence import Sequence from pulser_pasqal import BaseConfig, EmulatorType, Endpoints, PasqalCloud @@ -56,7 +59,7 @@ class CloudFixture: @pytest.fixture def seq(): - reg = Register.square(2, spacing=10, prefix="q") + reg = SquareLatticeLayout(5, 5, 5).make_mappable_register(10) return Sequence(reg, test_device) @@ -64,51 +67,45 @@ def seq(): def mock_job(): @dataclasses.dataclass class MockJob: + runs = 10 variables = {"t": 100, "qubits": {"q0": 1, "q1": 2, "q2": 4, "q3": 3}} result = {"00": 5, "11": 5} + def __post_init__(self) -> None: + self.id = str(np.random.randint(10000)) + return MockJob() @pytest.fixture def mock_batch(mock_job, seq): - with pytest.warns(UserWarning): - seq_ = seq.build() - seq_.declare_channel("rydberg_global", "rydberg_global") - seq_.measure() + seq_ = copy.deepcopy(seq) + seq_.declare_channel("rydberg_global", "rydberg_global") + seq_.measure() @dataclasses.dataclass class MockBatch: id = "abcd" status = "DONE" - jobs = {"job1": mock_job} + jobs = {mock_job.id: mock_job} sequence_builder = seq_.to_abstract_repr() return MockBatch() @pytest.fixture -def fixt(monkeypatch, mock_batch): +def fixt(mock_batch): with patch("pasqal_cloud.SDK", autospec=True) as mock_cloud_sdk_class: pasqal_cloud_kwargs = dict( username="abc", password="def", - group_id="ghi", + project_id="ghi", endpoints=Endpoints(core="core_url"), webhook="xyz", ) pasqal_cloud = PasqalCloud(**pasqal_cloud_kwargs) - with pytest.raises(NotImplementedError): - pasqal_cloud.fetch_available_devices() - - monkeypatch.setattr( - PasqalCloud, - "fetch_available_devices", - lambda _: {test_device.name: test_device}, - ) - mock_cloud_sdk_class.assert_called_once_with(**pasqal_cloud_kwargs) mock_cloud_sdk = mock_cloud_sdk_class.return_value @@ -117,6 +114,9 @@ def fixt(monkeypatch, mock_batch): mock_cloud_sdk.create_batch = MagicMock(return_value=mock_batch) mock_cloud_sdk.get_batch = MagicMock(return_value=mock_batch) + mock_cloud_sdk.get_device_specs_dict = MagicMock( + return_value={test_device.name: test_device.to_abstract_repr()} + ) yield CloudFixture( pasqal_cloud=pasqal_cloud, mock_cloud_sdk=mock_cloud_sdk @@ -152,11 +152,17 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): ): fixt.pasqal_cloud.submit(seq2, job_params=[dict(runs=10)]) + 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" ): - fixt.pasqal_cloud.submit(seq, job_params=[{"runs": 100}]) + fixt.pasqal_cloud.submit( + seq.build(qubits={"q0": 1, "q1": 2, "q2": 4, "q3": 3}), + job_params=[{"runs": 10}], + ) assert not seq.is_measured() config = EmulatorConfig( @@ -175,7 +181,16 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): == sdk_config ) - job_params = [{"runs": 10, "variables": {"t": 100}}] + job_params = [ + { + "runs": 10, + "variables": { + "t": np.array(100), # Check that numpy array is converted + "qubits": {"q0": 1, "q1": 2, "q2": 4, "q3": 3}, + }, + } + ] + remote_results = fixt.pasqal_cloud.submit( seq, job_params=job_params, @@ -192,20 +207,41 @@ def test_submit(fixt, parametrized, emulator, seq, mock_job): emulator=emulator, configuration=sdk_config, wait=False, - fetch_results=False, ) ) + job_params[0]["runs"] = 1 + with pytest.raises(RuntimeError, match="Failed to find job ID"): + # Job runs don't match MockJob + fixt.pasqal_cloud.submit( + seq, + job_params=job_params, + emulator=emulator, + config=config, + ) + + job_params[0]["runs"] = {10} + with pytest.raises( + TypeError, match="Object of type set is not JSON serializable" + ): + # Check that the decoder still fails on unsupported types + fixt.pasqal_cloud.submit( + seq, + job_params=job_params, + emulator=emulator, + config=config, + ) + assert isinstance(remote_results, RemoteResults) assert remote_results.get_status() == SubmissionStatus.DONE fixt.mock_cloud_sdk.get_batch.assert_called_once_with( - id=remote_results._submission_id, fetch_results=False + id=remote_results._submission_id ) fixt.mock_cloud_sdk.get_batch.reset_mock() results = remote_results.results fixt.mock_cloud_sdk.get_batch.assert_called_with( - id=remote_results._submission_id, fetch_results=True + id=remote_results._submission_id ) assert results == ( SampledResult( @@ -264,25 +300,30 @@ def test_emulators_run(fixt, seq, emu_cls, parametrized: bool): emu = emu_cls(seq, fixt.pasqal_cloud) - bad_kwargs = {} if parametrized else {"job_params": [{"runs": 100}]} - err_msg = ( - "'job_params' must be provided" - if parametrized - else "'job_params' cannot be provided" - ) - with pytest.raises(ValueError, match=err_msg): - emu.run(**bad_kwargs) + with pytest.raises(ValueError, match="'job_params' must be specified"): + emu.run() - good_kwargs = ( - {"job_params": [{"variables": {"t": 100}}]} if parametrized else {} - ) + with pytest.raises(ValueError, match="must specify 'runs'"): + emu.run(job_params=[{}]) + + good_kwargs = { + "job_params": [ + { + "runs": 10, + "variables": { + "t": 100, + "qubits": {"q0": 1, "q1": 2, "q2": 4, "q3": 3}, + }, + } + ] + } remote_results = emu.run(**good_kwargs) assert isinstance(remote_results, RemoteResults) sdk_config: EmuTNConfig | EmuFreeConfig if isinstance(emu, EmuTNBackend): emulator_type = EmulatorType.EMU_TN - sdk_config = EmuTNConfig() + sdk_config = EmuTNConfig(dt=1.0) else: emulator_type = EmulatorType.EMU_FREE sdk_config = EmuFreeConfig() @@ -293,7 +334,6 @@ def test_emulators_run(fixt, seq, emu_cls, parametrized: bool): emulator=emulator_type, configuration=sdk_config, wait=False, - fetch_results=False, ) diff --git a/tutorials/advanced_features/Backends for Sequence Execution.ipynb b/tutorials/advanced_features/Backends for Sequence Execution.ipynb new file mode 100644 index 000000000..00170c733 --- /dev/null +++ b/tutorials/advanced_features/Backends for Sequence Execution.ipynb @@ -0,0 +1,457 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6f230abe", + "metadata": {}, + "source": [ + "# Backend Execution of Pulser Sequences" + ] + }, + { + "cell_type": "markdown", + "id": "ae508ab2", + "metadata": {}, + "source": [ + "When the time comes to execute a Pulser sequence, there are many options: one can choose to execute it on a QPU or on an emulator, which might happen locally or remotely. All these options are accessible through an unified interface we call a `Backend`. \n", + "\n", + "This tutorial is a step-by-step guide on how to use the different backends for Pulser sequence execution." + ] + }, + { + "cell_type": "markdown", + "id": "a7601ae9", + "metadata": {}, + "source": [ + "## 1. Choosing the type of backend\n", + "\n", + "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", + "\n", + "### 1.1. Starting a remote connection\n", + "\n", + "For remote backend execution, start by ensuring that you have access and start a remote connection. For `PasqalCloud`, we could start one by running:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ef3cc2eb", + "metadata": {}, + "outputs": [], + "source": [ + "from pulser_pasqal import PasqalCloud\n", + "\n", + "connection = PasqalCloud(\n", + " username=USERNAME, # Your username or email address for the Pasqal Cloud Platform\n", + " project_id=PROJECT_ID, # The ID of the project associated to your account\n", + " password=PASSWORD, # The password for your Pasqal Cloud Platform account\n", + " **kwargs\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "29cff577", + "metadata": {}, + "source": [ + "### 1.2. Preparation for execution on `QPUBackend`\n", + "\n", + "Sequence execution on a QPU is done through the `QPUBackend`, which is a remote backend. Therefore, it requires a remote backend connection, which should be open from the start due to two additional QPU constraints:\n", + "\n", + "1. The `Device` must be chosen among the options available at the moment, which can be found through `connection.fetch_available_devices()`.\n", + "2. The `Register` must be defined from one of the register layouts calibrated for the chosen `Device`, which are found under `Device.calibrated_register_layouts`. Check out [this tutorial](reg_layouts.nblink) for more information on how to define a `Register` from a `RegisterLayout`.\n", + "\n", + "On the contrary, execution on emulator backends imposes no further restriction on the device and the register. We will stick to emulator backends in this tutorial, so we will forego the requirements of QPU backends in the following steps." + ] + }, + { + "cell_type": "markdown", + "id": "35a4f10c", + "metadata": {}, + "source": [ + "## 2. Creating the Pulse Sequence" + ] + }, + { + "cell_type": "markdown", + "id": "122a3c37", + "metadata": {}, + "source": [ + "The next step is to create the sequence that we want to execute. Here, we make a sequence with a variable duration combining a Blackman waveform in amplitude and a ramp in detuning. Since it will be executed on an emulator, we can create the register we want and choose a `VirtualDevice` that does not impose hardware restrictions (like the `MockDevice`)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4548fedd", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from pulser import Sequence, Pulse, Register\n", + "from pulser.devices import MockDevice\n", + "from pulser.waveforms import BlackmanWaveform, RampWaveform" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "57e088c6", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABp0AAAF8CAYAAAA9/HMGAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAADJFElEQVR4nOzdd3jb5b3+8bf2sizJezt770kIISFsCITRNARaxoEOuuF3WqDnlDIP7enpbg+0FCinpaxAwp4hAwgjezlxth3vLdmyLQ/p94dikUAgCbYjO7lf16VL0lePvs9HwXjo1vN5DJFIJIKIiIiIiIiIiIiIiIhINxjjXYCIiIiIiIiIiIiIiIj0fwqdREREREREREREREREpNsUOomIiIiIiIiIiIiIiEi3KXQSERERERERERERERGRblPoJCIiIiIiIiIiIiIiIt2m0ElERERERERERERERES6TaGTiIiIiIiIiIiIiIiIdJtCJxEREREREREREREREek2hU4iIiIiIiIiIiIiIiLSbQqdREREREREREREREREpNvM8S5A4qehoYFNmzbh9/uJRCLdPp/RaMTr9TJp0iRcLlcPVCgiIiIiIiIiIiIiIv2FQqdT0Icffsh9997Dm2+9RXt7R4+f32G3cdFFF3PX3XczZsyYHj+/iIiIiIiIiIiIiIj0PYZITyxxkX7jvffe44Lzz2Vwajs3ntnJuWMhOQGMPdBosTMM1QF4dSM8vNJEQ1si7yxfydixY7t/chERERERERERERER6dMUOp0CwuEwZWVlOBwORgwfyug0P6/8exiXvffmrGuCuf9lotU2gI8+XofBYOi9yURERERERERERERE5JhFIhEaGxvJysrC2BOrUg5S6HQKKCkpITc3N3Z/7X0weWDvz/vKBpj3P70/j4iIiIiIiIiIiIiIHL8DBw6Qk5PTY+fTnk6nALfbDcBNN93Emy/8nUkDen4fpyM5dyy47EZ+csfP+dGPfnTMz3vuuee48sore68wEREREREREREREZFTWCAQIDc3N5Yf9BSFTqeArtZ2gUCAASkRTlSnO6sZspJMBAIBEhMTj/l5drv9uMaLiIiIiIiIiIiIiMjx6+mtcXquUZ/0eR0dHVjNJ7abos0M7e3tx/WcwYMH91I1IiIiIiIiIiIiIiLSWxQ6SZ+TnZ0d7xJEREREREREREREROQ4KXSSPmfVqlXxLkFERERERERERERERI6TQic5bn9ZBqN+HO8qRERERERERERERESkL1HoJMetphEKy3vv/HPmzOm9k4uIiIiIiIiIiIiISK9Q6CR9TnFxcbxLEBERERERERERERGR46TQSfqcvXv3xrsEERERERERERERERE5TgqdpM8xm83xLkFERERERERERERERI6TQifpcxYsWBDvEkRERERERERERERE5DhpSYkA8JtXj33s+zt7rw6A5557jiuvvLJ3JxERERERERERERERkR6l0Kkf6Ozs5K677uKf//wnFRUVZGVlcf311/Of//mfGAyGHpnj3/91fON7ZtYja2tr68Wzi4iIiIiIiIiIiIhIb1Do1A/88pe/5MEHH+Txxx9n9OjRrF27lhtuuAGPx8MPfvCDHplj+X/0yGl6RH5+frxLEBERERERERERERGR46TQqR9YvXo18+fP5+KLLwZgwIABPPnkk3z88cc9NsfskT12qm4bPHhwvEsQEREREREREREREZHjZIx3AXJ0p59+OsuWLWPnzuhmSps2beK9997jwgsvPOL4UChEIBA47NKTPt4D336kR095mHfeeaf3Ti4iIiIiIiIiIiIiIr1CK536gdtvv51AIMCIESMwmUx0dnZy//33c8011xxx/AMPPMDdd9/dozXUNsL/vQeProCC0uixh27s0SlERERERERERERERKQfU+jUDzzzzDM88cQT/Otf/2L06NFs3LiRH/3oR2RlZXHdddd9Zvwdd9zBrbfeGrsfCATIzc097nkjEXh9EzyyAl7eCG0dMCAFbr0IrpzajRd0FGeccUbvnVxERERERERERERERHqFQqd+4Mc//jG33347V111FQBjx46lqKiIBx544Iihk81mw2azfen59lZFVzQ9/i6U1UOCHdo74E/XwXfO/dKnPWZVVVVfKiQTEREREREREREREZH4UejUDzQ3N2M0Hr79lslkIhwO99gcrW3w7Efw6EpYtQPMJrh4Alx/JgzNgNG3QYa3x6b7Qjt37mTy5MknZjIREREREREREREREekRCp36gUsuuYT777+fvLw8Ro8ezYYNG/jNb37Dv/3bv/XYHBnfhcYWmJAPv/s6XH06JLujj+2p7LFpRERERERERERERETkJKXQqR/44x//yM9+9jO+853vUFVVRVZWFt/6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SqAlQFFtEYWVheyr2ceuql00tzUzOW8ys4fP5vxR53Pa4NMUQomIyOfqrdBJH4M+Ao/HQ3V19TGPX7x4MQUFBdx55529WNUn/H4/wOcGMw888AB33333CamlN7zxxhtqrycip5yG5gbe2/UeywuX886Od9hSuoXUhFSGpA1heMZwzht1Hqnu1FjIZLPY4l2yiIiIiIjEgd1ix26xk5aYxtSBU2lua8bf4mdP1R52Ve7izW1v8uCKB2lpa2FC7gTOHHYm5406j5lDZuK0ffE2EiIiIt2llU5HcN5557Fjxw727NmDxWL5wrEffPABZ555JuFwuNf2dDpUOBzm0ksvpaGhgffee++IY/r7Sift6SQip4LaplpW7VzF8h3LWV64nILyAjI9mQxJG0J+Uj45STmkJqTitrtJdCTqE4oiIiIiInJUkUiElvYW/M3+6F5QVXvYX7ufPdV7aGxtZGzOWM4ceibnjT6PWUNnaU8oEZFTWJ9or2cy9cwm5AaDgY6Ojh45V2947LHHuPHGG7nlllv49a9//bnjVq1axYIFC6iursZgMJyQ0Onmm2/mtdde47333iMnJ+eYntPf9nTau3cvgwYNincZIiI9qjJQycrClSwvXM6KwhUUVhSSl5THoLRB0ZDJl0OyKxm33Y3b7sZhdcS7ZBERERER6ecODaGKaovYXb2botoi9lTvoS5Yx+is0cwaOotzR53LnOFz8Dq98S5ZREROkD4ROhmNxp6Z9AQFNF9WZ2cnp59+OmvXrmX+/Pn853/+J5MmTQKiK402b97M//7v//L4449jtVqZOXMmb731Vq+/pu9973u88MILrFq1ioEDBx7z8/pb6LR582bGjRsX7zJERL60SCRCYUUhq/esZuXOlby/+332Vu9lYMpABqUOIteXS15yHj6nLxYyaSWTiIiIiIj0tq4QqrG1kQO1B9hZtZOiuiL2Vu+lurGaYenDOG3QacwePpuzhp9FXlIeBoMh3mWLiEgv6BOh08qVK3ts4tmzZ/fYuXpDRUUFF198MRs2bMBgMOB0OvF4PFRVVdHZ2UkkEiErK4tnnnmGt956i3vuuafXQqdIJML3v/99lixZwooVKxg6dOhxPb+/hU5qryci/U1reytr96/lvd3vsXLnSj7a+xHBUJDBaYPJT8on25dNXtLhIZPVbI132SIiIiIiIrS2t9LY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ERGR7vv0Fi7d1e3QacWKFbHbXcV9evHUkY4bDAbuvPNOzj77bB5//HEyMzO7W8pJy2q1MnnyZJYtW8Zll10GRIOZZcuW8b3vfS++xfWCwYMHx7sEERERERERERERERE5Tt0OnX7+85/T3t7Ogw8+SH19PXl5ecyePZvs7GwASktLWbVqFUVFRSQlJfHtb3+bpqYm1q5dy+rVq1m2bBnnn38+H3/8MXa7vdsv6GR16623ct111zFlyhSmTZvG7373O4LBIDfccEO8S+txXV87IiIiIiIiIiIiIiLSf3Q7dPqP//gPzjnnHFpbW/n73//Otddee8Rx//jHP/j2t7/Nxx9/zOuvv47RaGTlypVcdtllbNu2jYcffpjvf//73S3npLVw4UKqq6u58847qaioYMKECbz++uukp6fHu7Qet2rVKhYtWhTvMkRERERERERERERE5DgYu3uC3/72t7z77rv84Q9/+NzACeDrX/86f/jDH1i2bBm///3vAZg9eza/+MUviEQiPPfcc90t5aT3ve99j6KiIkKhEB999BHTp0+Pd0kiIiIiIiIiIiIiIiIAGCKf3oDpOE2YMIHt27fT2NiI1Wr9wrGhUIjExERGjRrFhg0bAPD7/SQlJZGUlER1dXV3SpHPEQgE8Hg8+P1+EhMT413OUZWXl2uPLxERERERERERERGRXtJbuUG3Vzrt2bOHhISEowZOADabjYSEBHbv3h075vF48Hq9BAKB7pYiJ4ni4uJ4lyAiIiIiIiIiIiIiIsep26GT2WymoaGB8vLyo44tLy+noaEBs/nwraSam5vxeDzdLUVOEnv37o13CSIiIiIiIiIiIiIicpy6HTpNmjQJgNtuu+2oY2+//XYikUjsOQCVlZWEQiHS09O7W4qcJD4dSoqIiIiIiIiIiIiISN/X7dDpBz/4AZFIhCeeeIILL7yQd999l46OjtjjHR0drFq1iosuuoh//vOfGAwGfvCDH8Qef/311wGYPn16d0uRk8SCBQviXYKIiIiIiIiIiIiIiBynbodO8+fP59ZbbyUSifDmm28yZ84cEhISyM7OJicnh4SEBM466yzeeOMNIpEIP/rRj5g/f37s+WvWrGH8+PFcdtll3S1FThLPPfdcvEsQEREREREREREREZHj1CN9zP7nf/6HKVOmcOedd7J7927a2to+s8fTkCFDuPvuu1m0aNFhx//0pz/1RAlyEmlra4t3CSIiIiIiIiIiIiIicpx6bPOcq666iquuuoqNGzeyfv16qqurAUhNTWXSpElMmDChp6aSk1x+fn68SxARERERERERERERkePU7dDpnnvuAeCGG24gNzeXCRMmKGD6AnfddRd33333YceGDx/Ojh07Pvc5zz77LD/72c/Yv38/Q4cO5Ze//CUXXXRRb5caN4MHD453CSIiIiIiIiIiIiIicpy6vafT3XffzX333UdGRkZP1HNKGD16NOXl5bHLe++997ljV69ezaJFi7jxxhvZsGEDl112GZdddhlbt249gRWfWO+88068SxARERERERERERERkePU7dApJSWFxMRELBZLT9RzSjCbzWRkZMQuKSkpnzv297//PRdccAE//vGPGTlyJPfeey+TJk3SXlgiIiIiIiIiIiIiItKndLu93vjx43nnnXeora0lOTm5J2o66e3atYusrCzsdjszZszggQceIC8v74hjP/jgA2699dbDjp1//vksXbr0c88fCoUIhUKx+4FAoEfqPlHOOOOMeJcgIiIiIiIiIiIiIv1AJBKhpamNhsog/qog9ZVBGiqaqCtrpK68iRt/cy6uRHu8yzxldDt0+ta3vsXbb7/Nb37zG+6///6eqOmkNn36dP7+978zfPhwysvLufvuu5k1axZbt27F7XZ/ZnxFRQXp6emHHUtPT6eiouJz53jggQc+s29Uf1JVVUVubm68yxARERERERERERGROIhEIgQbWqMBUmWQhsom6iuD1JU1Ul/eRH1FkIaqJvzVzQRqWmhv7cBkNuLy2nAm2rAnWHG4rdgTrOxZX8G4OQPi/ZJOGd0Ona688kpuvfVWfvGLX9De3s5PfvKTL2wXd6q78MILY7fHjRvH9OnTyc/P55lnnuHGG2/skTnuuOOOw1ZHBQKBfhXi7Ny5k8mTJ8e7DBERERERERERERHpIeFwhMba5sOCpLryJurKmmioiIZKDVVBAtVBGmtb6GgPY7GZcHntONxWHF1BksuKI8GCJy3tk2MJVuwuC2arCZPFhNlixGQ2Ul/ehMXW7RhEjkO3/7Xnzp0LgMvl4te//jW//e1vGTJkCGlpaZhMpiM+x2AwsGzZsu5OfVLwer0MGzaM3bt3H/HxjIwMKisrDztWWVlJRkbG557TZrNhs9l6tE4RERER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DI4+h07jxo3TihUrlMlk5HQ6t3puOp1We3u7xo8f39fL9puf/vSnev/99zVv3rxBud4zzzzT4/H999+v2tpaLV26VNOmTdvi6ywWi+rr6we6vCHhySef1IknnljuMgAAAAAAAPqsI5NTdF0hQOoMkwpj7doLQdLauCLr44ptSCgeTsk0VQiNgl0hUqH7yKFgrVd1OwTl8hvyFMMlp8fRswup244km4P9SACAwdXn0Omkk07SZZddpgcffFBnnnnmVs/93//9X3V0dOikk07q62U/l6uvvnqrz2ezWc2bN08tLS2DUk9votGoJKmysnKr57W3t2vMmDHK5/Paa6+99Itf/EK77bZbr+em02ml0+nS41gs1n8FAwAAAAAAbOdSiQ5F1rYrui6hyNp2hVvaix1Jhd9H1sYVXR9XdENCyVhGFovkDhjyBgy5A065fIbcPocMj0PVzX417lJV2I9UDJmcLnuvIZLdYZXVTpAEABi6LKZpmn15g46ODs2cOVOvvvqq7rzzTp122mm9nvfggw/q7LPP1j777KMFCxbIbu9z3vWZrNatz5bt/D/oE088UX/4wx8GvJ5N5fN5HXXUUYpEIlq8ePEWz1uyZInee+897b777opGo7rxxhu1aNEivf3222pqatrs/CuvvFJXXXXVZsej0agCgUC/foaBsHz58iHVDQcAAAAAAEY20zSVbMv0GGsXWRvXxuJYu3BL12i72Iak0okOWW0WeYKGPAGjML7O37UfyfA4ZHidpeNun0N2w94tPOq5J8lmZz8SAAyET99r1fipTZowdfOfo2/vYrGYgsFgv+cG2xQ6balzKJ1O64477lAsFlNzc7NmzJhR2vG0evVqLVy4UKtWrVIwGNT3vvc9OZ1OXXHFFf3zCbZi4cKFvR5PpVL697//rdtvv13ZbFb/+Mc/VFdXN+D1bOqcc87RX//6Vy1evLjX8GhLOjo6NGHCBJ144om65pprNnu+t06n5uZmQicAAAAAALDdME1T7eFUoROpGCKFW9q1cXVb6feRdXHF1icU25BQRzonm90qb4VLnkBnWGTI8Dnk8jrk8jjk8hbH3fmdcnmdshu2UoBU2pNU7FCy2giSAKDcCJ22bEiETlbr1tt3O99q03N6O57L5bap0IEQi8U0adIk7bfffnrkkUcG9drnnXeeHn/8cS1atEhjx47d5tcf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cqVmz55dlvoAAAAAANumI5NTdH28W5AUL461K+xHCre0K7q+MNauPZySmTfl8jrkCRpy+41C55HPKZfPoUCNWzVjAqUuJbfPKafHUQiRunUhdd+TRJAEAAAAbFmfO506ZbNZPfHEE7r77rv197//Xfl8vuzdT/l8XmeddZbuu+++0uJUq9WqfD4vqTCH+4wzztDvfve7Eb1Ydbh1Om3cuFFVVVXlLgMAAADAIEknO7pCpM79SGvaS6PtImvjpSApEU1LkjwBoxgkdYZIhf1Ihtchl8dRDJIMufxOOV32HgFS9z1JNrt1RP99EAAAYHtGp9OWDVRu0G+hU3erVq3SPffco/vvv1+rV68uXMhiKVv308KFC/XAAw/ozTffVDQaVTAY1B577KFTTjlFM2bMGLQ6ymW4hU4vvPCCDjrooHKXAQAAAOALMk1TyfZMj7F2XR1Jhf1IkXVxRdcVgqRUvEMWq0XeYDFI8hWCJKMzSOo21s7tM+T2O2Q37KXuox57korHAAAAAEKnLRtWoVOnfD6vv/zlL/rd736np59+Wrlcrkf309lnn60DDjhgoC4/pN1+++264YYb1NLSoj322EO33Xab9t133y2e/+ijj+ryyy/XypUrtfPOO+v666/XYYcd9rmuNdxCJ3Y6AQAAAEOPaZqKR1IKdwuSwi3xUjdSuCWuyLrO0XZJdaSystmt8gQNeQOFjiO3v9iR5OkeJBnFgMkhh9PeNcquW4hkd1hltREkAQAAYNsQOm3ZkN3ptDVWq1VHHnmkHA6HNm7cqCVLlsg0TaVSKT300EOaM2eOpkyZoltuuUX77LPPQJYypDz88MO6+OKLddddd2m//fbTLbfcolmzZmnFihWqra3d7PwXX3xRJ554ombPnq0jjjhCc+bM0dFHH63XXntNEydOLMMnGFgej6fcJQAAAADbhXzeVNvGRI8gqTDWrl3hNW0Kd461Wx9X28aksh152Z02eSsMeQJGaReSy+uU2+dQsLZW7mKQ5PIXupTsTlup+6h7iGSzEyQBAAAAI82AdTqtWbNG9957r37/+9/ro48+UudlDjzwQB1//PH629/+pr/+9a/K5/NyOBz6v//7vwEddZfL5bRhwwal0+lenx89evSAXXtT++23n/bZZx/95je/kVToCGtubtb555+vSy65ZLPzv/nNbyoej+upp54qHZsy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V12lSCTyuV9zww03aObMmQMWOEnS2Wefrddee03PPvvsgF0DAAAAwPBD6AQAAAAAI8S6dev0l7/8Rd/4xjcG9DoTJkzQxIkTdf/99w/odQAAAAAML4ROAAAAADAEXXnllfrRj34kSRo7dqwsFossFotWrly5xdf85S9/UTab1cEHH9zj+P333y+LxaJ//OMfuvjii1VTUyOv16tjjjlG69ev73Huq6++qlmzZqm6ulput1tjx47Vt7/97c2u9V//9V968sknxcR2AAAAAJ3s5S4AAAAAALC5Y489Vv/+9781d+5c3XzzzaqurpYk1dTUbPE1L774oqqqqjRmzJhenz///PMVCoX085//XCtXrtQtt9yi8847Tw8//LCkQqfUIYccopqaGl1yySWqqKjQypUr9ac//Wmz95o8ebJuvvlmvf3225o4cWI/fGIAAAAAwx2hEwAAAAAMQbvvvrv22msvzZ07V0cffbR22GGHz3zN8uXLt3peVVWV/va3v8lisUiS8vm8br31VkWjUQWDQb344osKh8P629/+pr333rv0umuvvXaz9xo3bpwk6Z133iF0AgAAACCJ8XoAAAAAMGJs3LhRoVBoi89/5zvfKQVOknTQQQcpl8vpo48+kiRVVFRIkp566il1dHRs9Vqd19mwYUMfqwYAAAAwUhA6AQAAAMAIsrUdS6NHj+7xuDM4CofDkqTp06fruOOO01VXXaXq6mp9/etf13333ad0Or3F63QPsQAAAABs3widAAAAAGCEqKqqKgVIvbHZbL0e7x4g/fGPf9SSJUt03nnnafXq1fr2t7+tyZMnq729vcdrOq/TuWsKAAAAAAidAAAAAGCI2tYuovHjx+vDDz/s83WnTJmi6667Tq+++qoeeughvf3225o3b16PczqvM2HChD5fDwAAAMDIQOgEAAAAAEOU1+uVJEUikc91/tSpUxUOh/XBBx98oeuFw+HNxvPtueeekrTZiL2lS5cqGAxqt912+0LXAgAAADDy2MtdAAAAAACgd5MnT5Yk/fSnP9UJJ5wgh8OhI488shRGberwww+X3W7X3//+d33nO9/Z5us98MADuuOOO3TMMcdoxx13VFtbm+655x4FAgEddthhPc6dP3++jjzySHY6AQAAACghdAIAAACAIWqfffbRNddco7vuukvPPPOM8vm8Pvzwwy2GTnV1dTrssMP0yCOPfKHQafr06frnP/+pefPmae3atQoGg9p333310EMPaezYsaXzli9frmXLlumWW275oh8NAAAAwAhkMTednQAAAAAAGLZeeOEFzZgxQ8uXL9fOO+88INe48MILtWjRIi1dupROJwAAAAAlhE4AAAAAMMIceuihampq0j333NPv771x40aNGTNGjzzyyGYj9wAAAABs3widAAAAAAAAAAAA0GfWchcAAAAAAAAAAACA4Y/QCQAAAAAAAAAAAH1G6AQAAAAAAAAAAIA+I3QCAAAAAAAAAABAnxE6AQAAAAAAAAAAoM8InQAAAAAAAAAAANBnhE4AAAAAAAAAAADoM0InAAAAAAAAAAAA9BmhEwAAAAAAAAAAAPqM0AkAAAAAAAAAAAB99v8BxnBRMMkPEOIAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "reg = Register({\"q0\": (-5, 0), \"q1\": (5, 0)})\n", + "\n", + "seq = Sequence(reg, MockDevice)\n", + "seq.declare_channel(\"rydberg_global\", \"rydberg_global\")\n", + "t = seq.declare_variable(\"t\", dtype=int)\n", + "\n", + "amp_wf = BlackmanWaveform(t, np.pi)\n", + "det_wf = RampWaveform(t, -5, 5)\n", + "seq.add(Pulse(amp_wf, det_wf, 0), \"rydberg_global\")\n", + "\n", + "# We build with t=1000 so that we can draw it\n", + "seq.build(t=1000).draw()" + ] + }, + { + "cell_type": "markdown", + "id": "deb625b6", + "metadata": {}, + "source": [ + "## 3. Starting the backend" + ] + }, + { + "cell_type": "markdown", + "id": "953eab2e", + "metadata": {}, + "source": [ + "It is now time to select and initialize the backend. Currently, these are the available backends (but bear in mind that the list may grow in the future):\n", + "\n", + " - **Local**: \n", + " - `QutipBackend` (from `pulser_simulation`): Uses `QutipEmulator` to emulate the sequence execution locally.\n", + " - **Remote**:\n", + " - `QPUBackend` (from `pulser`): Executes on a QPU through a remote connection.\n", + " - `EmuFreeBackend` (from `pulser_pasqal`): Emulates the sequence execution using free Hamiltonian time evolution (similar to `QutipBackend`, but runs remotely). \n", + " - `EmuTNBackend` (from `pulser_pasqal`): Emulates the sequence execution using a tensor network simulator." + ] + }, + { + "cell_type": "markdown", + "id": "438c3cca", + "metadata": {}, + "source": [ + "Instead of choosing one, here we will import the three emulator backends so that we can compare them." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c508a2d8", + "metadata": {}, + "outputs": [], + "source": [ + "from pulser_simulation import QutipBackend\n", + "from pulser_pasqal import EmuFreeBackend, EmuTNBackend" + ] + }, + { + "cell_type": "markdown", + "id": "365ed331", + "metadata": {}, + "source": [ + "Upon creation, all backends require the sequence they will execute. Emulator backends also accept, optionally, a configuration given as an instance of the `EmulatorConfig` class. This class allows for setting all the parameters available in `QutipEmulator` and is forward looking, meaning that it envisions that these options will at some point be availabe on other emulator backends. This also means that trying to change parameters in the configuration of a backend that does not support them yet will raise an error.\n", + "\n", + "Even so, `EmulatorConfig` also has a dedicated `backend_options` for options specific to each backend, which are detailed in the [backends' docstrings](../apidoc/backend.rst)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "37b68469", + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.backend import EmulatorConfig" + ] + }, + { + "cell_type": "markdown", + "id": "21f506c5", + "metadata": {}, + "source": [ + "With `QutipBackend`, we have free reign over the configuration. In this example, we will:\n", + " \n", + "- Change the `sampling_rate`\n", + "- Include measurement errors using a custom `NoiseModel`\n", + "\n", + "On the other hand, `QutipBackend` does not support parametrized sequences. Since it is running locally, they can always be built externally before being given to the backend. Therefore, we will build the sequence (with `t=2000`) before we give it to the backend." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6f64a5af", + "metadata": {}, + "outputs": [], + "source": [ + "from pulser.backend import NoiseModel\n", + "\n", + "config = EmulatorConfig(\n", + " sampling_rate=0.1,\n", + " noise_model=NoiseModel(\n", + " noise_types=(\"SPAM\",),\n", + " p_false_pos=0.01,\n", + " p_false_neg=0.004,\n", + " state_prep_error=0.0,\n", + " ),\n", + ")\n", + "\n", + "qutip_bknd = QutipBackend(seq.build(t=2000), config=config)" + ] + }, + { + "cell_type": "markdown", + "id": "e74755e3", + "metadata": {}, + "source": [ + "Currently, the remote emulator backends are still quite limited in the number of parameters they allow to be changed. Furthermore, the default configuration of a given backend does not necessarily match that of `EmulatorConfig()`, so it's important to start from the correct default configuration. Here's how to do that for the `EmuTNBackend`:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "0889e0ba", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import replace\n", + "\n", + "emu_tn_default = EmuTNBackend.default_config\n", + "# This will create a new config with a different sampling rate\n", + "# All other parameters remain the same\n", + "emu_tn_config = replace(emu_tn_default, sampling_rate=0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "21f4ee21", + "metadata": {}, + "source": [ + "We will stick to the default configuration for `EmuFreeBackend`, but the process to create a custom configuration would be identical. To know which parameters can be changed, consult the backend's docstring." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "59d5e3ca", + "metadata": {}, + "outputs": [], + "source": [ + "free_bknd = EmuFreeBackend(seq, connection=connection)\n", + "tn_bknd = EmuTNBackend(seq, connection=connection, config=emu_tn_config)" + ] + }, + { + "cell_type": "markdown", + "id": "50729b54", + "metadata": {}, + "source": [ + "Note also that the remote backends require an open connection upon initialization. This would also be the case for `QPUBackend`." + ] + }, + { + "cell_type": "markdown", + "id": "51cce28c", + "metadata": {}, + "source": [ + "## 4. Executing the Sequence" + ] + }, + { + "cell_type": "markdown", + "id": "f4590ab7", + "metadata": {}, + "source": [ + "Once the backend is created, executing the sequence is always done through the backend's `run()` method.\n", + "\n", + "For the `QutipBackend`, all arguments are optional and are the same as the ones in `QutipEmulator`. On the other hand, remote backends all require `job_params` to be specified. `job_params` are given as a list of dictionaries, each containing the number of runs and the values for the variables of the parametrized sequence (if any). The sequence is then executed with the parameters specified within each entry of `job_params`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "22e8f95b", + "metadata": {}, + "outputs": [], + "source": [ + "# Local execution, returns the same results as QutipEmulator\n", + "qutip_results = qutip_bknd.run()\n", + "\n", + "# Remote execution, requires job_params\n", + "job_params = [\n", + " {\"runs\": 100, \"variables\": {\"t\": 1000}},\n", + " {\"runs\": 50, \"variables\": {\"t\": 2000}},\n", + "]\n", + "free_results = free_bknd.run(job_params=job_params)\n", + "tn_results = tn_bknd.run(job_params=job_params)" + ] + }, + { + "cell_type": "markdown", + "id": "4421eb27", + "metadata": {}, + "source": [ + "## 5. Retrieving the Results" + ] + }, + { + "cell_type": "markdown", + "id": "8289b06f", + "metadata": {}, + "source": [ + "For the `QutipBackend` the results are identical to those of `QutipEmulator`: a sequence of individual `QutipResult` objects, one for each evaluation time. As usual we can, for example, get the final state:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c920679c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "Quantum object: dims = [[2, 2], [1, 1]], shape = (4, 1), type = ket $ \\\\ \\left(\\begin{matrix}(-0.380-0.157j)\\\\(0.035+0.593j)\\\\(0.035+0.593j)\\\\(-0.235-0.263j)\\\\\\end{matrix}\\right)$" + ], + "text/plain": [ + "Quantum object: dims = [[2, 2], [1, 1]], shape = (4, 1), type = ket\n", + "Qobj data =\n", + "[[-0.38024396-0.15656328j]\n", + " [ 0.03529282+0.59329452j]\n", + " [ 0.03529282+0.59329452j]\n", + " [-0.23481812-0.26320141j]]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "qutip_results[-1].state" + ] + }, + { + "cell_type": "markdown", + "id": "2618a789", + "metadata": {}, + "source": [ + "For remote backends, the object returned is a `RemoteResults` instance, which uses the connection to fetch the results once they are ready. To check the status of the submission, we can run:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d24593f4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "free_results.get_status()" + ] + }, + { + "cell_type": "markdown", + "id": "763e011c", + "metadata": {}, + "source": [ + "When the submission states shows as `DONE`, the results can be accessed. In this case, they are a sequence of `SampledResult` objects, one for each entry in `job_params` in the same order. For example, we can retrieve the bitstring counts or even plot an histogram with the results:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "738de317", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'00': 5, '01': 13, '10': 26, '11': 56}\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "print(free_results[0].bitstring_counts)\n", + "free_results[0].plot_histogram()" + ] + }, + { + "cell_type": "markdown", + "id": "579c9417", + "metadata": {}, + "source": [ + "The same could be done with the results from `EmuTNBackend` or even from `QPUBackend`, as they all share the same format." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pulser-dev", + "language": "python", + "name": "pulser-dev" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 59651f2d5fce2b96717df8dc8367996291d850eb Mon Sep 17 00:00:00 2001 From: Andrea Basilio Rava <48324096+andreabasiliorava@users.noreply.github.com> Date: Thu, 13 Jul 2023 14:47:27 +0200 Subject: [PATCH 15/19] Update QAOA and QAA to solve a QUBO problem.ipynb (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update QAOA and QAA to solve a QUBO problem.ipynb Edited the total number of bit string to check as 2**len(Q) in the 3rd code block * Fix formatting --------- Co-authored-by: Henrique Silvério --- .../applications/QAOA and QAA to solve a QUBO problem.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb index 1011f5752..e1b105e5d 100644 --- a/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb +++ b/tutorials/applications/QAOA and QAA to solve a QUBO problem.ipynb @@ -83,7 +83,7 @@ "metadata": {}, "outputs": [], "source": [ - "bitstrings = [np.binary_repr(i, len(Q)) for i in range(len(Q) ** 2)]\n", + "bitstrings = [np.binary_repr(i, len(Q)) for i in range(2 ** len(Q))]\n", "costs = []\n", "# this takes exponential time with the dimension of the QUBO\n", "for b in bitstrings:\n", From ad4f74bde9c5e9429269a0a3fb807d1f6c8b65dc Mon Sep 17 00:00:00 2001 From: Antoine Cornillot <61453516+a-corni@users.noreply.github.com> Date: Thu, 13 Jul 2023 16:29:29 +0200 Subject: [PATCH 16/19] Modify API Reference on Classical Simulation (#556) * Modify API * Delete Simulation from doc * Add Simulation to docs * Modify module description, deprecation note * Taking into account review comments --- docs/source/apidoc/simulation.rst | 11 +++++++++-- pulser-simulation/pulser_simulation/simulation.py | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/source/apidoc/simulation.rst b/docs/source/apidoc/simulation.rst index 0d5a5a9de..46c49f870 100644 --- a/docs/source/apidoc/simulation.rst +++ b/docs/source/apidoc/simulation.rst @@ -5,12 +5,19 @@ Classical Simulation Since version 0.6.0, all simulation classes (previously under the ``pulser.simulation`` module) are in the ``pulser-simulation`` extension and should be imported from ``pulser_simulation``. -Simulation +QutipEmulator ---------------------- -.. automodule:: pulser_simulation.simulation +:class:`QutipEmulator` is the class to simulate :class:`SequenceSamples`, that are samples of a :class:`Sequence`. +It is possible to simulate directly a :class:`Sequence` object by using the class method +``QutipEmulator.from_sequence``. Since version 0.14.0, the :class:`Simulation` class is deprecated +in favour of :class:`QutipEmulator`. + +.. autoclass:: pulser_simulation.simulation.QutipEmulator :members: +.. autoclass:: pulser_simulation.simulation.Simulation + SimConfig ---------------------- diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index 170c5d0b3..e2f36b1ba 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Contains the Simulation class, used for simulation of a Sequence.""" +"""Defines the QutipEmulator, used to simulate a Sequence or its samples.""" from __future__ import annotations @@ -1156,6 +1156,9 @@ def from_sequence( class Simulation: r"""Simulation of a pulse sequence using QuTiP. + Warning: + This class is deprecated in favour of ``QutipEmulator.from_sequence``. + Args: sequence: An instance of a Pulser Sequence that we want to simulate. From f01fe5d44fcdb547c93688bfec0048acf23e37c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Thu, 13 Jul 2023 16:45:59 +0200 Subject: [PATCH 17/19] Add optional fields to device and channel classes (#553) * Add multiple beam control option in RydbergEOM * Add `Device.max_sequence_duration` * Add `Channel.min_amp_area` * Add `BaseDevice.max_runs` * UTs for channel and EOM * UTs for BaseDevice additions * Add xfail tests for serialization * Adressing review comments * min_amp_area -> min_avg_amp * Add optional BaseEOM.custom_buffer_time * Restore backwards compatibility in BaseDevice * Clarifying `eom_buffer_time` * Use 'max_runs' on QPUBackend * Complete docstrings * Update the JSON schema --- pulser-core/pulser/backend/qpu.py | 16 +++- pulser-core/pulser/channels/base_channel.py | 42 +++++++++- pulser-core/pulser/channels/eom.py | 81 +++++++++++++++---- pulser-core/pulser/devices/_device_datacls.py | 29 ++++++- .../pulser/json/abstract_repr/deserializer.py | 27 +++++-- .../abstract_repr/schemas/device-schema.json | 72 +++++++++++++++++ pulser-core/pulser/json/utils.py | 12 +++ pulser-core/pulser/sampler/samples.py | 50 +++++++++--- pulser-core/pulser/sequence/_schedule.py | 35 ++++++-- pulser-core/pulser/sequence/sequence.py | 4 +- tests/test_abstract_repr.py | 48 +++++++++++ tests/test_backend.py | 14 +++- tests/test_channels.py | 54 ++++++++++++- tests/test_devices.py | 20 +++-- tests/test_eom.py | 16 +++- tests/test_sequence.py | 39 ++++++++- 16 files changed, 496 insertions(+), 63 deletions(-) diff --git a/pulser-core/pulser/backend/qpu.py b/pulser-core/pulser/backend/qpu.py index ef3d11751..925d6d0d2 100644 --- a/pulser-core/pulser/backend/qpu.py +++ b/pulser-core/pulser/backend/qpu.py @@ -42,10 +42,18 @@ def run(self, job_params: list[JobParams] | None = None) -> RemoteResults: suffix = " when executing a sequence on a real QPU." if not job_params: raise ValueError("'job_params' must be specified" + suffix) - if any("runs" not in j for j in job_params): - raise ValueError( - "All elements of 'job_params' must specify 'runs'" + suffix - ) + + max_runs = self._sequence.device.max_runs + for j in job_params: + if "runs" not in j: + raise ValueError( + "All elements of 'job_params' must specify 'runs'" + suffix + ) + if max_runs is not None and j["runs"] > max_runs: + raise ValueError( + "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 ) diff --git a/pulser-core/pulser/channels/base_channel.py b/pulser-core/pulser/channels/base_channel.py index a8344ca07..3afabd99a 100644 --- a/pulser-core/pulser/channels/base_channel.py +++ b/pulser-core/pulser/channels/base_channel.py @@ -25,7 +25,7 @@ from scipy.fft import fft, fftfreq, ifft from pulser.channels.eom import MODBW_TO_TR, BaseEOM -from pulser.json.utils import obj_to_dict +from pulser.json.utils import get_dataclass_defaults, obj_to_dict from pulser.pulse import Pulse # Warnings of adjusted waveform duration appear just once @@ -33,6 +33,8 @@ ChannelType = TypeVar("ChannelType", bound="Channel") +OPTIONAL_ABSTR_CH_FIELDS = ("min_avg_amp",) + @dataclass(init=True, repr=False, frozen=True) class Channel(ABC): @@ -55,6 +57,7 @@ class Channel(ABC): clock cycle. min_duration: The shortest duration an instruction can take. max_duration: The longest duration an instruction can take. + min_avg_amp: The minimum average amplitude of a pulse (when not zero). mod_bandwidth: The modulation bandwidth at -3dB (50% reduction), in MHz. @@ -72,6 +75,7 @@ class Channel(ABC): clock_period: int = 1 # ns min_duration: int = 1 # ns max_duration: Optional[int] = int(1e8) # ns + min_avg_amp: int = 0 mod_bandwidth: Optional[float] = None # MHz eom_config: Optional[BaseEOM] = field(init=False, default=None) @@ -110,11 +114,13 @@ def __post_init__(self) -> None: "min_duration", "max_duration", "mod_bandwidth", + "min_avg_amp", ] non_negative = [ "max_abs_detuning", "min_retarget_interval", "fixed_retarget_t", + "min_avg_amp", ] local_only = [ "min_retarget_interval", @@ -253,6 +259,8 @@ def Local( duration an instruction can take. mod_bandwidth(Optional[float], default=None): The modulation bandwidth at -3dB (50% reduction), in MHz. + min_avg_amp: The minimum average amplitude of a pulse (when not + zero). """ return cls( "Local", @@ -288,6 +296,8 @@ def Global( duration an instruction can take. mod_bandwidth(Optional[float], default=None): The modulation bandwidth at -3dB (50% reduction), in MHz. + min_avg_amp: The minimum average amplitude of a pulse (when not + zero). """ return cls("Global", max_abs_detuning, max_amp, **kwargs) @@ -356,6 +366,12 @@ def validate_pulse(self, pulse: Pulse) -> None: "The pulse's detuning values go out of the range " "allowed for the chosen channel." ) + avg_amp = np.average(pulse.amplitude.samples) + if 0 < avg_amp < self.min_avg_amp: + raise ValueError( + "The pulse's average amplitude is below the chosen " + f"channel's limit ({self.min_avg_amp})." + ) @property def _modulation_padding(self) -> int: @@ -487,6 +503,23 @@ def calc_modulation_buffer( return start, end + @property + def _eom_buffer_time(self) -> int: + # By definition, rise_time goes from 10% to 90% + # Roughly 2*rise_time is enough to go from 0% to 100% + # so we use that by default + assert self.supports_eom(), "Can't define the EOM buffer time." + return int( + cast(BaseEOM, self.eom_config).custom_buffer_time + or 2 * self.rise_time + ) + + @property + def _eom_buffer_mod_bandwidth(self) -> float: + # Takes half of the buffer time as the rise time + rise_time_us = self._eom_buffer_time / 2 * 1e-3 + return MODBW_TO_TR / rise_time_us + def __repr__(self) -> str: config = ( f".{self.addressing}(Max Absolute Detuning: " @@ -524,5 +557,10 @@ def _to_dict(self, _module: str = "pulser.channels") -> dict[str, Any]: return obj_to_dict(self, _module=_module, **params) def _to_abstract_repr(self, id: str) -> dict[str, Any]: - params = {f.name: getattr(self, f.name) for f in fields(self)} + all_fields = fields(self) + defaults = get_dataclass_defaults(all_fields) + params = {f.name: getattr(self, f.name) for f in all_fields} + for p in OPTIONAL_ABSTR_CH_FIELDS: + if params[p] == defaults[p]: + params.pop(p, None) return {"id": id, "basis": self.basis, **params} diff --git a/pulser-core/pulser/channels/eom.py b/pulser-core/pulser/channels/eom.py index 7ad50ba99..56455b6d3 100644 --- a/pulser-core/pulser/channels/eom.py +++ b/pulser-core/pulser/channels/eom.py @@ -21,11 +21,12 @@ import numpy as np -from pulser.json.utils import obj_to_dict +from pulser.json.utils import get_dataclass_defaults, obj_to_dict # 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") class RydbergBeam(Flag): @@ -41,17 +42,34 @@ def _to_abstract_repr(self) -> str: return cast(str, self.name) +# These tricks dividing the dataclass fields into those with +# and without defaults are necessary due to how dataclass +# inheritance works. Without this, we would have the keyword +# arguments of BaseEOM coming before the positional arguments +# of RydbergEOM, which simply fails. It's nasty but necessary +# until we can use the KW_ONLY option introduced in python 3.10 + + +@dataclass(frozen=True) +class _BaseEOM: + mod_bandwidth: float # MHz + + +@dataclass(frozen=True) +class _BaseEOMDefaults: + custom_buffer_time: int | None = None # ns + + @dataclass(frozen=True) -class BaseEOM: +class BaseEOM(_BaseEOMDefaults, _BaseEOM): """A base class for the EOM configuration. Attributes: mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), in MHz. + custom_buffer_time: A custom wait time to enforce during EOM buffers. """ - mod_bandwidth: float # MHz - def __post_init__(self) -> None: if self.mod_bandwidth <= 0.0: raise ValueError( @@ -63,6 +81,15 @@ def __post_init__(self) -> None: f"'mod_bandwidth' must be lower than {MODBW_TO_TR*1e3} MHz" ) + if ( + self.custom_buffer_time is not None + and int(self.custom_buffer_time) <= 0 + ): + raise ValueError( + "'custom_buffer_time' must be greater than zero, not" + f" {self.custom_buffer_time}." + ) + @property def rise_time(self) -> int: """The rise time (in ns). @@ -79,28 +106,51 @@ def _to_dict(self) -> dict[str, Any]: return obj_to_dict(self, **params) def _to_abstract_repr(self) -> dict[str, Any]: - return {f.name: getattr(self, f.name) for f in fields(self)} + all_fields = fields(self) + params = {} + defaults = get_dataclass_defaults(all_fields) + assert set(OPTIONAL_ABSTR_EOM_FIELDS) <= defaults.keys() + for f in all_fields: + value = getattr(self, f.name) + if ( + f.name in OPTIONAL_ABSTR_EOM_FIELDS + and value == defaults[f.name] + ): + continue + params[f.name] = value + return params @dataclass(frozen=True) -class RydbergEOM(BaseEOM): +class _RydbergEOM: + limiting_beam: RydbergBeam + max_limiting_amp: float # rad/µs + intermediate_detuning: float # rad/µs + controlled_beams: tuple[RydbergBeam, ...] + + +@dataclass(frozen=True) +class _RydbergEOMDefaults: + multiple_beam_control: bool = True + + +@dataclass(frozen=True) +class RydbergEOM(_RydbergEOMDefaults, BaseEOM, _RydbergEOM): """The EOM configuration for a Rydberg channel. Attributes: - mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), - in MHz. limiting_beam: The beam with the smallest amplitude range. max_limiting_amp: The maximum amplitude the limiting beam can reach, in rad/µs. intermediate_detuning: The detuning between the two beams, in rad/µs. controlled_beams: The beams that can be switched on/off with an EOM. + mod_bandwidth: The EOM modulation bandwidth at -3dB (50% reduction), + in MHz. + 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. """ - limiting_beam: RydbergBeam - max_limiting_amp: float # rad/µs - intermediate_detuning: float # rad/µs - controlled_beams: tuple[RydbergBeam, ...] - def __post_init__(self) -> None: super().__post_init__() for param in ["max_limiting_amp", "intermediate_detuning"]: @@ -163,8 +213,9 @@ def detuning_off_options( self._lightshift(rabi_frequency, beam) for beam in self.controlled_beams ] - # Case where both beams are off ie (OFF, OFF) -> no lightshift - lightshifts.append(0.0) + if self.multiple_beam_control: + # Case where both beams are off ie (OFF, OFF) -> no lightshift + lightshifts.append(0.0) # We sum the offset to all lightshifts to get the effective detuning return np.array(lightshifts) + offset diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index 671bf780e..370cefbd2 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -27,13 +27,15 @@ from pulser.devices.interaction_coefficients import c6_dict 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.json.utils import get_dataclass_defaults, obj_to_dict from pulser.register.base_register import BaseRegister, QubitId from pulser.register.mappable_reg import MappableRegister from pulser.register.register_layout import COORD_PRECISION, RegisterLayout DIMENSIONS = Literal[2, 3] +ALWAYS_OPTIONAL_PARAMS = ("max_sequence_duration", "max_runs") + @dataclass(frozen=True, repr=False) class BaseDevice(ABC): @@ -62,6 +64,10 @@ class BaseDevice(ABC): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + max_sequence_duration: The maximum allowed duration for a sequence + (in ns). + max_runs: The maximum number of runs allowed on the device. Only used + for backend execution. """ name: str dimensions: DIMENSIONS @@ -72,6 +78,8 @@ class BaseDevice(ABC): interaction_coeff_xy: float | None = None supports_slm_mask: bool = False max_layout_filling: float = 0.5 + max_sequence_duration: int | None = None + max_runs: int | None = None reusable_channels: bool = field(default=False, init=False) channel_ids: tuple[str, ...] | None = None channel_objects: tuple[Channel, ...] = field(default_factory=tuple) @@ -103,9 +111,14 @@ def type_check( "min_atom_distance", "max_atom_num", "max_radial_distance", + "max_sequence_duration", + "max_runs", ): value = getattr(self, param) - if param in self._optional_parameters: + if ( + param in self._optional_parameters + or param in ALWAYS_OPTIONAL_PARAMS + ): prelude = "When defined, " is_none = value is None elif value is None: @@ -408,9 +421,13 @@ def _to_dict(self) -> dict[str, Any]: @abstractmethod def _to_abstract_repr(self) -> dict[str, Any]: ex_params = ("channel_objects", "channel_ids") + defaults = get_dataclass_defaults(fields(self)) params = self._params() for p in ex_params: params.pop(p, None) + for p in ALWAYS_OPTIONAL_PARAMS: + if params[p] == defaults[p]: + params.pop(p, None) ch_list = [] for ch_name, ch_obj in self.channels.items(): ch_list.append(ch_obj._to_abstract_repr(ch_name)) @@ -449,6 +466,10 @@ class Device(BaseDevice): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + max_sequence_duration: The maximum allowed duration for a sequence + (in ns). + max_runs: The maximum number of runs allowed on the device. Only used + for backend execution. pre_calibrated_layouts: RegisterLayout instances that are already available on the Device. """ @@ -586,6 +607,10 @@ class VirtualDevice(BaseDevice): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + max_sequence_duration: The maximum allowed duration for a sequence + (in ns). + max_runs: The maximum number of runs allowed on the device. Only used + for backend execution. reusable_channels: Whether each channel can be declared multiple times on the same pulse sequence. """ diff --git a/pulser-core/pulser/json/abstract_repr/deserializer.py b/pulser-core/pulser/json/abstract_repr/deserializer.py index e56d90ba9..8413d62a2 100644 --- a/pulser-core/pulser/json/abstract_repr/deserializer.py +++ b/pulser-core/pulser/json/abstract_repr/deserializer.py @@ -25,7 +25,11 @@ import pulser.devices as devices from pulser.channels import Microwave, Raman, Rydberg from pulser.channels.base_channel import Channel -from pulser.channels.eom import RydbergBeam, RydbergEOM +from pulser.channels.eom import ( + OPTIONAL_ABSTR_EOM_FIELDS, + RydbergBeam, + RydbergEOM, +) from pulser.devices import Device, VirtualDevice from pulser.json.abstract_repr.signatures import ( BINARY_OPERATORS, @@ -33,6 +37,7 @@ ) from pulser.json.abstract_repr.validation import validate_abstract_repr from pulser.json.exceptions import AbstractReprError, DeserializeDeviceError +from pulser.json.utils import get_dataclass_defaults from pulser.parametrized import ParamObj, Variable from pulser.pulse import Pulse from pulser.register.mappable_reg import MappableRegister @@ -281,6 +286,11 @@ def _deserialize_channel(obj: dict[str, Any]) -> Channel: if obj["eom_config"] is not None: data = obj["eom_config"] try: + optional = { + key: data[key] + for key in OPTIONAL_ABSTR_EOM_FIELDS + if key in data + } params["eom_config"] = RydbergEOM( mod_bandwidth=data["mod_bandwidth"], limiting_beam=RydbergBeam[data["limiting_beam"]], @@ -289,6 +299,7 @@ def _deserialize_channel(obj: dict[str, Any]) -> Channel: controlled_beams=tuple( RydbergBeam[beam] for beam in data["controlled_beams"] ), + **optional, ) except ValueError as e: raise AbstractReprError( @@ -300,8 +311,11 @@ def _deserialize_channel(obj: dict[str, Any]) -> Channel: channel_cls = Microwave # No other basis allowed by the schema - for param in dataclasses.fields(channel_cls): - if param.init and param.name != "eom_config": + channel_fields = dataclasses.fields(channel_cls) + channel_defaults = get_dataclass_defaults(channel_fields) + for param in channel_fields: + use_default = param.name not in obj and param.name in channel_defaults + if param.init and param.name != "eom_config" and not use_default: params[param.name] = obj[param.name] try: return channel_cls(**params) @@ -333,8 +347,11 @@ def _deserialize_device_object(obj: dict[str, Any]) -> Device | VirtualDevice: channel_ids=tuple(ch_ids), channel_objects=tuple(ch_objs) ) ex_params = ("channel_objects", "channel_ids") - for param in dataclasses.fields(device_cls): - if not param.init or param.name in ex_params: + device_fields = dataclasses.fields(device_cls) + device_defaults = get_dataclass_defaults(device_fields) + for param in device_fields: + use_default = param.name not in obj and param.name in device_defaults + if not param.init or param.name in ex_params or use_default: continue if param.name == "pre_calibrated_layouts": key = "pre_calibrated_layouts" 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 0bcfaffdd..f48833c58 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json @@ -54,6 +54,14 @@ "description": "Maximum distance an atom can be from the center of the array (in µm).", "type": "number" }, + "max_runs": { + "description": "The maximum number of runs allowed on the device. Only used for backend execution.", + "type": "number" + }, + "max_sequence_duration": { + "description": "The maximum allowed duration for a sequence (in ns).", + "type": "number" + }, "min_atom_distance": { "description": "The closest together two atoms can be (in μm).", "type": "number" @@ -156,6 +164,14 @@ "null" ] }, + "max_runs": { + "description": "The maximum number of runs allowed on the device. Only used for backend execution.", + "type": "number" + }, + "max_sequence_duration": { + "description": "The maximum allowed duration for a sequence (in ns).", + "type": "number" + }, "min_atom_distance": { "description": "The closest together two atoms can be (in μm).", "type": "number" @@ -262,6 +278,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -348,6 +368,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -434,6 +458,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -530,6 +558,10 @@ "null" ] }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -619,6 +651,10 @@ "null" ] }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -708,6 +744,10 @@ "null" ] }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -823,6 +863,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -900,6 +944,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -977,6 +1025,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "null" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -1061,6 +1113,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "number" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -1138,6 +1194,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "number" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -1215,6 +1275,10 @@ "description": "How many atoms can be locally addressed at once by the same beam.", "type": "number" }, + "min_avg_amp": { + "description": "The minimum average amplitude of a pulse (when not zero).", + "type": "number" + }, "min_duration": { "description": "The shortest duration an instruction can take.", "type": "number" @@ -1267,6 +1331,10 @@ }, "type": "array" }, + "custom_buffer_time": { + "description": "A custom wait time to enforce during EOM buffers.", + "type": "number" + }, "intermediate_detuning": { "description": "The detuning between the two beams, in rad/µs.", "type": "number" @@ -1282,6 +1350,10 @@ "mod_bandwidth": { "description": "The EOM modulation bandwidth at -3dB (50% reduction), in MHz.", "type": "number" + }, + "multiple_beam_control": { + "description": "Whether both EOMs can be used simultaneously or not.", + "type": "boolean" } }, "required": [ diff --git a/pulser-core/pulser/json/utils.py b/pulser-core/pulser/json/utils.py index 167daad98..09f064619 100644 --- a/pulser-core/pulser/json/utils.py +++ b/pulser-core/pulser/json/utils.py @@ -16,6 +16,7 @@ from __future__ import annotations import warnings +from dataclasses import MISSING, Field from typing import TYPE_CHECKING, Any, Optional, Sequence import pulser @@ -25,6 +26,17 @@ from pulser.register import QubitId +def get_dataclass_defaults(fields: tuple[Field, ...]) -> dict[str, Any]: + """Gets the defaults for the fields that have them.""" + defaults = {} + for field in fields: + if field.default is not MISSING: + defaults[field.name] = field.default + elif field.default_factory is not MISSING: + defaults[field.name] = field.default_factory() + return defaults + + def obj_to_dict( obj: object, *args: Any, diff --git a/pulser-core/pulser/sampler/samples.py b/pulser-core/pulser/sampler/samples.py index 625ec0fe6..97f8abe10 100644 --- a/pulser-core/pulser/sampler/samples.py +++ b/pulser-core/pulser/sampler/samples.py @@ -1,6 +1,7 @@ """Dataclasses for storing and processing the samples.""" from __future__ import annotations +import itertools from collections import defaultdict from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Optional, cast @@ -239,6 +240,17 @@ def masked(samples: np.ndarray, mask: np.ndarray) -> np.ndarray: # to the 'eom_mask' eom_mask = eom_mask + eom_mask_ext + eom_buffers_mask = np.zeros_like(eom_mask, dtype=bool) + for start, end in itertools.chain( + self.eom_start_buffers, self.eom_end_buffers + ): + eom_buffers_mask[start:end] = True + eom_buffers_mask = eom_buffers_mask & ~eom_mask_ext + buffer_ch_obj = replace( + channel_obj, + mod_bandwidth=channel_obj._eom_buffer_mod_bandwidth, + ) + if block.tf is None: # The sequence finishes in EOM mode, so 'end' was already # including the fall time (unlike when it is disabled). @@ -250,10 +262,24 @@ def masked(samples: np.ndarray, mask: np.ndarray) -> np.ndarray: # First, we modulated the pre-filtered standard samples, then # we mask them to include only the parts outside the EOM mask # This ensures smooth transitions between EOM and STD samples + key_samples = getattr(std_samples, key) modulated_std = channel_obj.modulate( - getattr(std_samples, key), keep_ends=key == "det" + key_samples, keep_ends=key == "det" + ) + if key == "det": + std_mask = ~(eom_mask + eom_buffers_mask) + # Adjusted detuning modulation during EOM buffers + modulated_buffer = buffer_ch_obj.modulate( + key_samples, keep_ends=True + ) + else: + std_mask = ~eom_mask + modulated_buffer = np.zeros_like(modulated_std) + + std = masked(modulated_std, std_mask) + buffers = masked( + modulated_buffer[: len(std)], eom_buffers_mask ) - std = masked(modulated_std, ~eom_mask) # At the end of an EOM block, the EOM(s) are switched back # to the OFF configuration, so the detuning should go quickly @@ -293,16 +319,18 @@ def masked(samples: np.ndarray, mask: np.ndarray) -> np.ndarray: # filtered to include only the parts inside the EOM mask eom = masked(modulated_eom, eom_mask) - # 'std' and 'eom' are then summed, but before the shortest - # array is extended so that they are of the same length - sample_arrs = [std, eom] + # 'std', 'eom' and 'buffers' are then summed, but before the + # short arrays are extended so that they are of the same length + sample_arrs = [std, eom, buffers] sample_arrs.sort(key=len) - # Extend shortest array to match the longest - sample_arrs[0] = np.pad( - sample_arrs[0], - (0, sample_arrs[1].size - sample_arrs[0].size), - ) - new_samples[key] = sample_arrs[0] + sample_arrs[1] + # Extend shortest arrays to match the longest before summing + new_samples[key] = sample_arrs[-1] + for arr in sample_arrs[:-1]: + arr = np.pad( + arr, + (0, sample_arrs[-1].size - arr.size), + ) + new_samples[key] = new_samples[key] + arr else: new_samples["amp"] = channel_obj.modulate(self.amp) diff --git a/pulser-core/pulser/sequence/_schedule.py b/pulser-core/pulser/sequence/_schedule.py index eb2e0f774..f2cad62ed 100644 --- a/pulser-core/pulser/sequence/_schedule.py +++ b/pulser-core/pulser/sequence/_schedule.py @@ -248,6 +248,10 @@ def __iter__(self) -> Iterator[_TimeSlot]: class _Schedule(Dict[str, _ChannelSchedule]): + def __init__(self, max_duration: int | None = None): + self.max_duration = max_duration + super().__init__() + def get_duration( self, channel: Optional[str] = None, include_fall_time: bool = False ) -> int: @@ -293,13 +297,13 @@ def enable_eom( if not _skip_buffer and self.get_duration(channel_id): # Wait for the last pulse to ramp down (if needed) self.wait_for_fall(channel_id) - # Account for time needed to ramp to desired amplitude - # By definition, rise_time goes from 10% to 90% - # Roughly 2*rise_time is enough to go from 0% to 100% + eom_buffer_time = self[channel_id].adjust_duration( + channel_obj._eom_buffer_time + ) if detuning_off != 0: self.add_pulse( Pulse.ConstantPulse( - 2 * channel_obj.rise_time, + eom_buffer_time, 0.0, detuning_off, self._get_last_pulse_phase(channel_id), @@ -309,7 +313,7 @@ def enable_eom( protocol="no-delay", ) else: - self.add_delay(2 * channel_obj.rise_time, channel_id) + self.add_delay(eom_buffer_time, channel_id) # Set up the EOM eom_settings = _EOMSettings( @@ -323,8 +327,16 @@ def enable_eom( def disable_eom(self, channel_id: str, _skip_buffer: bool = False) -> None: self[channel_id].eom_blocks[-1].tf = self[channel_id][-1].tf + channel_obj = self[channel_id].channel_obj + eom_config = channel_obj.eom_config if not _skip_buffer: - self.wait_for_fall(channel_id) + if eom_config and eom_config.custom_buffer_time: + eom_buffer_time = self[channel_id].adjust_duration( + channel_obj._eom_buffer_time + ) + self.add_delay(eom_buffer_time, channel_id) + else: + self.wait_for_fall(channel_id) def add_pulse( self, @@ -373,12 +385,14 @@ def add_pulse( ti = t0 + delay_duration tf = ti + pulse.duration + self._check_duration(tf) self[channel].slots.append(_TimeSlot(pulse, ti, tf, last.targets)) def add_delay(self, duration: int, channel: str) -> None: last = self[channel][-1] ti = last.tf tf = ti + self[channel].channel_obj.validate_duration(duration) + self._check_duration(tf) if ( self[channel].in_eom_mode() and self[channel].eom_blocks[-1].detuning_off != 0 @@ -416,7 +430,7 @@ def add_target(self, qubits_set: set[QubitId], channel: str) -> None: else: ti = -1 tf = 0 - + self._check_duration(tf) self[channel].slots.append( _TimeSlot("target", ti, tf, set(qubits_set)) ) @@ -468,3 +482,10 @@ def _get_last_pulse_phase(self, channel: str) -> float: except RuntimeError: phase = 0.0 return phase + + def _check_duration(self, t: int) -> None: + if self.max_duration is not None and t > self.max_duration: + raise RuntimeError( + "The sequence's duration exceeded the maximum duration allowed" + f" by the device ({self.max_duration} ns)." + ) diff --git a/pulser-core/pulser/sequence/sequence.py b/pulser-core/pulser/sequence/sequence.py index de5191409..f29c4b1b2 100644 --- a/pulser-core/pulser/sequence/sequence.py +++ b/pulser-core/pulser/sequence/sequence.py @@ -124,7 +124,9 @@ def __init__( self._calls: list[_Call] = [ _Call("__init__", (), {"register": register, "device": device}) ] - self._schedule: _Schedule = _Schedule() + self._schedule: _Schedule = _Schedule( + max_duration=device.max_sequence_duration + ) self._basis_ref: dict[str, dict[QubitId, _QubitRef]] = {} # IDs of all qubits in device self._qids: set[QubitId] = set(self._register.qubit_ids) diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 9d820cad9..046e31282 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -17,6 +17,7 @@ import re from collections.abc import Callable from copy import deepcopy +from dataclasses import replace from typing import Any, Type from unittest.mock import patch @@ -25,6 +26,8 @@ import pytest from pulser import Pulse, Register, Register3D, Sequence, devices +from pulser.channels import Rydberg +from pulser.channels.eom import RydbergBeam, RydbergEOM from pulser.devices import Chadoq2, Device, IroiseMVP, MockDevice from pulser.json.abstract_repr.deserializer import ( VARIABLE_TYPE_MAP, @@ -187,6 +190,51 @@ def check_error_raised( ) assert isinstance(prev_err.__cause__, ValueError) + @pytest.mark.parametrize("field", ["max_sequence_duration", "max_runs"]) + def test_optional_device_fields(self, field): + device = replace(MockDevice, **{field: 1000}) + dev_str = device.to_abstract_repr() + assert device == deserialize_device(dev_str) + + @pytest.mark.parametrize( + "ch_obj", + [ + Rydberg.Global(None, None, min_avg_amp=1), + 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), + multiple_beam_control=False, + ), + ), + 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), + custom_buffer_time=500, + ), + ), + ], + ) + def test_optional_channel_fields(self, ch_obj): + device = replace( + MockDevice, channel_objects=(ch_obj,), channel_ids=None + ) + dev_str = device.to_abstract_repr() + assert device == deserialize_device(dev_str) + def validate_schema(instance): with open( diff --git a/tests/test_backend.py b/tests/test_backend.py index 1fb282e87..2e12cca72 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -13,7 +13,9 @@ # limitations under the License. from __future__ import annotations +import re import typing +from dataclasses import replace import numpy as np import pytest @@ -246,7 +248,7 @@ def test_qpu_backend(sequence): ): QPUBackend(sequence, connection) - seq = sequence.switch_device(Chadoq2) + seq = sequence.switch_device(replace(Chadoq2, max_runs=10)) qpu_backend = QPUBackend(seq, connection) with pytest.raises(ValueError, match="'job_params' must be specified"): qpu_backend.run() @@ -254,7 +256,15 @@ def test_qpu_backend(sequence): ValueError, match="All elements of 'job_params' must specify 'runs'", ): - qpu_backend.run(job_params=[{"n_runs": 10}, {"runs": 1}]) + qpu_backend.run(job_params=[{"n_runs": 10}, {"runs": 11}]) + + with pytest.raises( + ValueError, + match=re.escape( + "All 'runs' must be below the maximum allowed by the device (10)" + ), + ): + qpu_backend.run(job_params=[{"runs": 11}]) remote_results = qpu_backend.run(job_params=[{"runs": 10}]) diff --git a/tests/test_channels.py b/tests/test_channels.py index 1a47c3a8a..5d948b949 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -18,6 +18,7 @@ import pytest import pulser +from pulser import Pulse from pulser.channels import Microwave, Raman, Rydberg from pulser.channels.eom import MODBW_TO_TR, BaseEOM, RydbergBeam, RydbergEOM from pulser.waveforms import BlackmanWaveform, ConstantWaveform @@ -33,6 +34,7 @@ ("max_duration", 0), ("mod_bandwidth", 0), ("mod_bandwidth", MODBW_TO_TR * 1e3 + 1), + ("min_avg_amp", -1e-3), ], ) def test_bad_init_global_channel(bad_param, bad_value): @@ -59,6 +61,7 @@ def test_bad_init_global_channel(bad_param, bad_value): ("max_duration", -1), ("mod_bandwidth", -1e4), ("mod_bandwidth", MODBW_TO_TR * 1e3 + 1), + ("min_avg_amp", -1e-3), ], ) def test_bad_init_local_channel(bad_param, bad_value): @@ -238,9 +241,10 @@ def test_modulation_errors(): ) _eom_rydberg = Rydberg.Global( max_amp=2 * np.pi * 10, - max_abs_detuning=2 * np.pi * 5, + max_abs_detuning=30, mod_bandwidth=10, eom_config=_eom_config, + min_avg_amp=1e-3, ) @@ -267,3 +271,51 @@ def test_modulation(channel, tr, eom, side_buffer_len): side_buffer_len, side_buffer_len, ) + + +@pytest.mark.parametrize( + "pulse, error, msg", + [ + ("π-pulse", TypeError, "must be of type Pulse"), + ( + Pulse.ConstantPulse(100, 1e6, 0, 0), + ValueError, + "amplitude goes over the maximum", + ), + ( + Pulse.ConstantPulse(100, 0, -1e4, 0), + ValueError, + "detuning values go out of the range", + ), + ( + Pulse.ConstantPulse(100, 0.99e-3, 0, 0), + ValueError, + re.escape( + "average amplitude is below the chosen channel's" + f" limit ({_eom_rydberg.min_avg_amp})" + ), + ), + ], +) +def test_validate_pulse_fail(pulse, error, msg): + with pytest.raises(error, match=msg): + _eom_rydberg.validate_pulse(pulse) + + +def test_validate_pulse_success(): + ch_obj = _eom_rydberg + # Pulse at max values still passes + pulse = Pulse.ConstantPulse( + 100, ch_obj.max_amp, ch_obj.max_abs_detuning, 0 + ) + assert ch_obj.max_amp > ch_obj.min_avg_amp + ch_obj.validate_pulse(pulse) + + # Pulse with zero amplitude is fine + pulse = Pulse.ConstantPulse(100, 0, ch_obj.max_abs_detuning, 0) + ch_obj.validate_pulse(pulse) + + # Pulse with the minimum average amplitude is also fine + amp_waveform = ConstantWaveform(100, ch_obj.min_avg_amp) + pulse = Pulse.ConstantDetuning(amp_waveform, -ch_obj.max_abs_detuning, 0) + ch_obj.validate_pulse(pulse) diff --git a/tests/test_devices.py b/tests/test_devices.py index 4bd3b4d22..c9981bdc8 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -74,6 +74,8 @@ def test_params(): "'interaction_coeff_xy' must be a 'float'," " not ''.", ), + ("max_sequence_duration", 1.02, None), + ("max_runs", 1e8, None), ], ) def test_post_init_type_checks(test_params, param, value, msg): @@ -117,6 +119,8 @@ def test_post_init_type_checks(test_params, param, value, msg): "When defined, the number of channel IDs must" " match the number of channel objects.", ), + ("max_sequence_duration", 0, None), + ("max_runs", 0, None), ], ) def test_post_init_value_errors(test_params, param, value, msg): @@ -126,17 +130,21 @@ def test_post_init_value_errors(test_params, param, value, msg): VirtualDevice(**test_params) -potential_params = ("max_atom_num", "max_radial_distance") +potential_params = ["max_atom_num", "max_radial_distance"] +always_none_allowed = ["max_sequence_duration", "max_runs"] -@pytest.mark.parametrize("none_param", potential_params) +@pytest.mark.parametrize("none_param", potential_params + always_none_allowed) def test_optional_parameters(test_params, none_param): test_params.update({p: 10 for p in potential_params}) test_params[none_param] = None - with pytest.raises( - TypeError, - match=f"'{none_param}' can't be None in a 'Device' instance.", - ): + if none_param not in always_none_allowed: + with pytest.raises( + TypeError, + match=f"'{none_param}' can't be None in a 'Device' instance.", + ): + Device(**test_params) + else: Device(**test_params) VirtualDevice(**test_params) # Valid as None on a VirtualDevice diff --git a/tests/test_eom.py b/tests/test_eom.py index 29d68e28e..7f428b05c 100644 --- a/tests/test_eom.py +++ b/tests/test_eom.py @@ -37,6 +37,8 @@ def params(): ("max_limiting_amp", 0), ("intermediate_detuning", -500), ("intermediate_detuning", 0), + ("custom_buffer_time", 0.1), + ("custom_buffer_time", 0), ], ) def test_bad_value_init_eom(bad_param, bad_value, params): @@ -91,8 +93,10 @@ def test_bad_controlled_beam(params): assert RydbergEOM(**params).controlled_beams == tuple(RydbergBeam) +@pytest.mark.parametrize("multiple_beam_control", [True, False]) @pytest.mark.parametrize("limit_amp_fraction", [0.5, 2]) -def test_detuning_off(limit_amp_fraction, params): +def test_detuning_off(multiple_beam_control, limit_amp_fraction, params): + params["multiple_beam_control"] = multiple_beam_control eom = RydbergEOM(**params) limit_amp = params["max_limiting_amp"] ** 2 / ( 2 * params["intermediate_detuning"] @@ -119,14 +123,18 @@ def calc_offset(amp): assert eom._lightshift(amp, *RydbergBeam) == -zero_det assert eom._lightshift(amp) == 0.0 det_off_options = eom.detuning_off_options(amp, detuning_on) + assert len(det_off_options) == 2 + multiple_beam_control det_off_options.sort() assert det_off_options[0] < zero_det # RED on - assert det_off_options[1] == zero_det # All off - assert det_off_options[2] > zero_det # BLUE on + next_ = 1 + if multiple_beam_control: + assert det_off_options[next_] == zero_det # All off + next_ += 1 + assert det_off_options[next_] > zero_det # BLUE on # Case where the EOM pulses are off-resonant detuning_on = 1.0 - for beam, ind in [(RydbergBeam.RED, 2), (RydbergBeam.BLUE, 0)]: + for beam, ind in [(RydbergBeam.RED, next_), (RydbergBeam.BLUE, 0)]: # When only one beam is controlled, there is a single # detuning_off option params["controlled_beams"] = (beam,) diff --git a/tests/test_sequence.py b/tests/test_sequence.py index c29c7f372..c5324b670 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -1402,8 +1402,21 @@ def test_multiple_index_targets(reg): assert built_seq._last("ch0").targets == {"q2", "q3"} -def test_eom_mode(reg, mod_device, patch_plt_show): - seq = Sequence(reg, mod_device) +@pytest.mark.parametrize("custom_buffer_time", (None, 400)) +def test_eom_mode(reg, mod_device, custom_buffer_time, patch_plt_show): + # Setting custom_buffer_time + channels = mod_device.channels + eom_config = dataclasses.replace( + channels["rydberg_global"].eom_config, + custom_buffer_time=custom_buffer_time, + ) + channels["rydberg_global"] = dataclasses.replace( + channels["rydberg_global"], eom_config=eom_config + ) + dev_ = dataclasses.replace( + mod_device, channel_ids=None, channel_objects=tuple(channels.values()) + ) + seq = Sequence(reg, dev_) seq.declare_channel("ch0", "rydberg_global") ch0_obj = seq.declared_channels["ch0"] assert not seq.is_in_eom_mode("ch0") @@ -1477,7 +1490,9 @@ def test_eom_mode(reg, mod_device, patch_plt_show): assert seq._schedule["ch0"].get_eom_mode_intervals() == eom_intervals buffer_delay = seq._schedule["ch0"][-1] assert buffer_delay.ti == last_pulse_slot.tf - assert buffer_delay.tf == buffer_delay.ti + eom_pulse.fall_time(ch0_obj) + assert buffer_delay.tf == buffer_delay.ti + ( + custom_buffer_time or eom_pulse.fall_time(ch0_obj) + ) assert buffer_delay.type == "delay" # Check buffer when EOM is not enabled at the start of the sequence @@ -1488,6 +1503,10 @@ def test_eom_mode(reg, mod_device, patch_plt_show): assert new_eom_block.detuning_off != 0 assert last_slot.ti == buffer_delay.tf # Nothing else was added duration = last_slot.tf - last_slot.ti + assert ( + duration == custom_buffer_time + or 2 * seq.declared_channels["ch0"].rise_time + ) # The buffer is a Pulse at 'detuning_off' and zero amplitude assert last_slot.type == Pulse.ConstantPulse( duration, 0.0, new_eom_block.detuning_off, last_pulse_slot.type.phase @@ -1547,3 +1566,17 @@ def test_eom_buffer( if non_zero_detuning_off else "delay" ) + + +def test_max_duration(reg, mod_device): + dev_ = dataclasses.replace(mod_device, max_sequence_duration=100) + seq = Sequence(reg, dev_) + seq.declare_channel("ch0", "rydberg_global") + seq.delay(100, "ch0") + catch_statement = pytest.raises( + RuntimeError, match="duration exceeded the maximum duration allowed" + ) + with catch_statement: + seq.delay(16, "ch0") + with catch_statement: + seq.add(Pulse.ConstantPulse(100, 1, 0, 0), "ch0") From d8235356e39420a3bfcdfd019adcd1e01329c372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= Date: Thu, 13 Jul 2023 17:51:35 +0200 Subject: [PATCH 18/19] Defining AnalogDevice (#559) * Defining AnalogDevice * Replacing IroiseMVP by AnalogDevice in the docs * Update tutorials using IroiseMVP * Update printed device specs --- docs/source/apidoc/core.rst | 2 +- pulser-core/pulser/devices/__init__.py | 8 +++-- pulser-core/pulser/devices/_device_datacls.py | 10 ++++-- pulser-core/pulser/devices/_devices.py | 30 +++++++++++++++++ tests/test_abstract_repr.py | 4 +-- .../Output Modulation and EOM Mode.ipynb | 32 ++++++++++++++----- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/docs/source/apidoc/core.rst b/docs/source/apidoc/core.rst index 82099fbe5..3b39d908a 100644 --- a/docs/source/apidoc/core.rst +++ b/docs/source/apidoc/core.rst @@ -96,7 +96,7 @@ which when associated with a :class:`pulser.Sequence` condition its development. .. autodata:: pulser.devices.Chadoq2 -.. autodata:: pulser.devices.IroiseMVP +.. autodata:: pulser.devices.AnalogDevice Channels diff --git a/pulser-core/pulser/devices/__init__.py b/pulser-core/pulser/devices/__init__.py index 21fa87ea9..3502506d9 100644 --- a/pulser-core/pulser/devices/__init__.py +++ b/pulser-core/pulser/devices/__init__.py @@ -18,9 +18,13 @@ from typing import TYPE_CHECKING from pulser.devices._device_datacls import Device, VirtualDevice -from pulser.devices._devices import Chadoq2, IroiseMVP +from pulser.devices._devices import AnalogDevice, Chadoq2, IroiseMVP from pulser.devices._mock_device import MockDevice # Registers which devices can be used to avoid definition of custom devices _mock_devices: tuple[VirtualDevice, ...] = (MockDevice,) -_valid_devices: tuple[Device, ...] = (Chadoq2, IroiseMVP) +_valid_devices: tuple[Device, ...] = ( + Chadoq2, + IroiseMVP, + AnalogDevice, +) diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index 370cefbd2..0e1940870 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -532,10 +532,15 @@ def _specs(self, for_docs: bool = False) -> str: ), f" - Maximum layout filling fraction: {self.max_layout_filling}", f" - SLM Mask: {'Yes' if self.supports_slm_mask else 'No'}", - "\nChannels:", ] - ch_lines = [] + if self.max_sequence_duration is not None: + lines.append( + " - Maximum sequence duration: " + f"{self.max_sequence_duration} ns" + ) + + ch_lines = ["\nChannels:"] for name, ch in self.channels.items(): if for_docs: ch_lines += [ @@ -552,6 +557,7 @@ def _specs(self, for_docs: bool = False) -> str: + r"- Maximum :math:`|\delta|`:" + f" {ch.max_abs_detuning:.4g} rad/µs" ), + f"\t- Minimum average amplitude: {ch.min_avg_amp} rad/µs", ] if ch.addressing == "Local": ch_lines += [ diff --git a/pulser-core/pulser/devices/_devices.py b/pulser-core/pulser/devices/_devices.py index d662125b0..85eac8155 100644 --- a/pulser-core/pulser/devices/_devices.py +++ b/pulser-core/pulser/devices/_devices.py @@ -17,6 +17,7 @@ from pulser.channels import Raman, Rydberg from pulser.channels.eom import RydbergBeam, RydbergEOM from pulser.devices._device_datacls import Device +from pulser.register.special_layouts import TriangularLatticeLayout Chadoq2 = Device( name="Chadoq2", @@ -82,3 +83,32 @@ ), ), ) + +AnalogDevice = Device( + name="AnalogDevice", + dimensions=2, + rydberg_level=60, + max_atom_num=25, + max_radial_distance=35, + min_atom_distance=5, + max_sequence_duration=4000, + # TODO: Define max_runs + channel_objects=( + Rydberg.Global( + max_abs_detuning=2 * np.pi * 20, + max_amp=2 * np.pi * 2, + clock_period=4, + min_duration=16, + mod_bandwidth=8, + eom_config=RydbergEOM( + limiting_beam=RydbergBeam.RED, + max_limiting_amp=30 * 2 * np.pi, + intermediate_detuning=450 * 2 * np.pi, + mod_bandwidth=40, + controlled_beams=(RydbergBeam.BLUE,), + custom_buffer_time=240, + ), + ), + ), + pre_calibrated_layouts=(TriangularLatticeLayout(61, 5),), +) diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 046e31282..e37afab4e 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -28,7 +28,7 @@ from pulser import Pulse, Register, Register3D, Sequence, devices from pulser.channels import Rydberg from pulser.channels.eom import RydbergBeam, RydbergEOM -from pulser.devices import Chadoq2, Device, IroiseMVP, MockDevice +from pulser.devices import AnalogDevice, Chadoq2, Device, IroiseMVP, MockDevice from pulser.json.abstract_repr.deserializer import ( VARIABLE_TYPE_MAP, deserialize_device, @@ -63,7 +63,7 @@ class TestDevice: - @pytest.fixture(params=[Chadoq2, IroiseMVP, MockDevice]) + @pytest.fixture(params=[Chadoq2, IroiseMVP, MockDevice, AnalogDevice]) def abstract_device(self, request): device = request.param return json.loads(device.to_abstract_repr()) diff --git a/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb b/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb index b097f3a5e..5451b11b1 100644 --- a/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb +++ b/tutorials/advanced_features/Output Modulation and EOM Mode.ipynb @@ -1,6 +1,7 @@ { "cells": [ { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -21,6 +22,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -28,6 +30,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -35,6 +38,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -57,6 +61,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -76,6 +81,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -98,6 +104,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -117,6 +124,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -124,6 +132,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -178,6 +187,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -192,6 +202,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -199,10 +210,11 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ - "In order to get the most realistic results when simulating a sequence, it may be valuable to use the expected output rather than the programmed input. To do so, one can simply initialize the `Simulation` class with `with_modulation=True`.\n", + "In order to get the most realistic results when simulating a sequence, it may be valuable to use the expected output rather than the programmed input. To do so, one can simply initialize the `QutipEmulator` class with `with_modulation=True`.\n", "Below, we simulate the sequence with and without modulation to assess the effect it has on the overlap between the resulting final states." ] }, @@ -212,10 +224,10 @@ "metadata": {}, "outputs": [], "source": [ - "from pulser_simulation import Simulation\n", + "from pulser_simulation import QutipEmulator\n", "\n", - "sim_in = Simulation(seq)\n", - "sim_out = Simulation(seq, with_modulation=True)\n", + "sim_in = QutipEmulator.from_sequence(seq)\n", + "sim_out = QutipEmulator.from_sequence(seq, with_modulation=True)\n", "\n", "input_final_state = sim_in.run().get_final_state()\n", "output_final_state = sim_out.run().get_final_state()\n", @@ -224,6 +236,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -231,6 +244,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -245,10 +259,11 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ - "Let us showcase these features with the `IroiseMVP` device, which features an EOM on its `rydberg_global` channel." + "Let us showcase these features with the `AnalogDevice` device, which features an EOM on its `rydberg_global` channel." ] }, { @@ -257,9 +272,9 @@ "metadata": {}, "outputs": [], "source": [ - "from pulser.devices import IroiseMVP\n", + "from pulser.devices import AnalogDevice\n", "\n", - "seq = Sequence(Register.square(2, spacing=6), IroiseMVP)\n", + "seq = Sequence(Register.square(2, spacing=6), AnalogDevice)\n", "seq.declare_channel(\"rydberg\", \"rydberg_global\")\n", "\n", "seq.add(Pulse.ConstantPulse(100, 1, 0, 0), \"rydberg\")\n", @@ -283,6 +298,7 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -306,7 +322,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.3" + "version": "3.8.5" } }, "nbformat": 4, From 523e916f974c6b89842608857559c3aa805c984a Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Thu, 13 Jul 2023 17:53:00 +0200 Subject: [PATCH 19/19] Bump version to 0.14.0 --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index a37900288..a803cc227 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.14dev3 +0.14.0