Skip to content

Commit

Permalink
Adding backends to create a uniform interface for sequence execution (p…
Browse files Browse the repository at this point in the history
…asqal-io#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
  • Loading branch information
HGSilveri authored May 26, 2023
1 parent a5b81ef commit b012b76
Show file tree
Hide file tree
Showing 20 changed files with 1,746 additions and 222 deletions.
14 changes: 14 additions & 0 deletions pulser-core/pulser/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -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."""
45 changes: 45 additions & 0 deletions pulser-core/pulser/backend/abc.py
Original file line number Diff line number Diff line change
@@ -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)}."
)
125 changes: 125 additions & 0 deletions pulser-core/pulser/backend/config.py
Original file line number Diff line number Diff line change
@@ -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)}."
)
205 changes: 205 additions & 0 deletions pulser-core/pulser/backend/noise_model.py
Original file line number Diff line number Diff line change
@@ -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}."
)
Loading

0 comments on commit b012b76

Please sign in to comment.