Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Defining the new Backend API classes #764

Open
wants to merge 33 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
eb8c13d
Introducing the Results class
HGSilveri Oct 31, 2024
c62e7b9
Defining State and Operator ABCs
HGSilveri Nov 4, 2024
ebb26e7
Defining the Observable ABC
HGSilveri Nov 5, 2024
6ab3158
Updates to State and Operator ABCs
HGSilveri Nov 7, 2024
d4a7fad
Introducing EmulationConfig
HGSilveri Nov 8, 2024
ab92b0d
Define the default observables
HGSilveri Nov 14, 2024
0310199
Define the EmulatorBackend ABC
HGSilveri Nov 15, 2024
694c3d3
Implement more flexible evaluation time matching
HGSilveri Nov 18, 2024
6e49225
Finish up changes in the config base classes
HGSilveri Nov 19, 2024
4527cbf
Include total sequence duration in Results
HGSilveri Nov 19, 2024
09e42e7
Import sorting
HGSilveri Nov 19, 2024
67197da
Fix typing issues
HGSilveri Nov 19, 2024
eb3c490
Applying review suggestions
HGSilveri Dec 3, 2024
92a7dad
Rename QubitDensity -> Occupation
HGSilveri Dec 3, 2024
65daf47
Change State to require `probabilities()` instead of `sample()`
HGSilveri Dec 3, 2024
0c70b71
Docstring updates
HGSilveri Dec 6, 2024
7b5a293
Removing 'custom_operators' option
HGSilveri Dec 6, 2024
85aa542
Definition of QutipState
HGSilveri Dec 6, 2024
dcff769
Definition of QutipOp
HGSilveri Dec 9, 2024
495c325
Remove Results.meas_basis
HGSilveri Dec 10, 2024
ab1dfab
Revert to having `State.sample()` as the abstractmethod
HGSilveri Dec 12, 2024
28c9c57
Incorporating UUIDs in observables
HGSilveri Dec 13, 2024
67da4ae
Add Results.get_tagged_results()
HGSilveri Dec 13, 2024
796ef43
Misc adjustments
HGSilveri Dec 13, 2024
66b9cd7
Merge branch 'develop' into hs/backend-api
HGSilveri Dec 30, 2024
1f48fbb
First UTs for pulser.backend classes
HGSilveri Dec 30, 2024
21a0ffa
UTs for QutipState and QutipOperator
HGSilveri Dec 31, 2024
4bcc8e2
Unit tests for observables
HGSilveri Jan 3, 2025
dc26f3b
Unit tests for Results
HGSilveri Jan 3, 2025
e7e5739
Make UT compatible with Python 3.9
HGSilveri Jan 3, 2025
1728341
Fix docs build
HGSilveri Jan 3, 2025
4177377
Expose relevant new classes
HGSilveri Jan 3, 2025
b0aa05c
Merge branch 'develop' into hs/backend-api
HGSilveri Jan 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pulser-core/pulser/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
from pulser.backend.config import EmulatorConfig
from pulser.noise_model import NoiseModel # For backwards compat
from pulser.backend.qpu import QPUBackend
from pulser.backend.results import Results

__all__ = ["EmulatorConfig", "NoiseModel", "QPUBackend"]
__all__ = ["EmulatorConfig", "NoiseModel", "QPUBackend", "Results"]
46 changes: 37 additions & 9 deletions pulser-core/pulser/backend/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,38 @@
"""Base class for the backend interface."""
from __future__ import annotations

import typing
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import ClassVar

import pulser
from pulser.backend.config import EmulationConfig
from pulser.backend.results import Results
from pulser.devices import Device
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, mimic_qpu: bool = False) -> None:
def __init__(
self, sequence: pulser.Sequence, mimic_qpu: bool = False
) -> None:
"""Starts a new backend instance."""
self.validate_sequence(sequence, mimic_qpu=mimic_qpu)
self._sequence = sequence
self._mimic_qpu = bool(mimic_qpu)

@abstractmethod
def run(self) -> Results | typing.Sequence[Results]:
def run(self) -> Results | Sequence[Results]:
"""Executes the sequence on the backend."""
pass

@staticmethod
def validate_sequence(sequence: Sequence, mimic_qpu: bool = False) -> None:
def validate_sequence(
sequence: pulser.Sequence, mimic_qpu: bool = False
) -> None:
"""Validates a sequence prior to submission."""
if not isinstance(sequence, Sequence):
if not isinstance(sequence, pulser.Sequence):
raise TypeError(
"'sequence' should be a `Sequence` instance"
f", not {type(sequence)}."
Expand Down Expand Up @@ -70,3 +74,27 @@ def validate_sequence(sequence: Sequence, mimic_qpu: bool = False) -> None:
"the register's layout must be one of the layouts available "
f"in '{device.name}.calibrated_register_layouts'."
)


class EmulatorBackend(Backend):
"""The emulator backend parent class."""

default_config: ClassVar[EmulationConfig]

def __init__(
self,
sequence: pulser.Sequence,
config: EmulationConfig | None = None,
mimic_qpu: bool = False,
) -> None:
"""Initializes the backend."""
super().__init__(sequence, mimic_qpu=mimic_qpu)
config = config or self.default_config
if not isinstance(config, EmulationConfig):
raise TypeError(
"'config' must be an instance of 'EmulationConfig', "
f"not {type(config)}."
)
self._config = config
# See the BackendConfig definition to see how this would work
self._config = type(self.default_config)(**config._backend_options)
211 changes: 204 additions & 7 deletions pulser-core/pulser/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,223 @@
"""Defines the backend configuration classes."""
from __future__ import annotations

import copy
import warnings
from dataclasses import dataclass, field
from typing import Any, Literal, Sequence, get_args
from typing import (
Any,
Generic,
Literal,
Sequence,
SupportsFloat,
TypeVar,
cast,
get_args,
)

import numpy as np
from numpy.typing import ArrayLike

import pulser.math as pm
from pulser.backend.observable import Observable
from pulser.backend.state import State
from pulser.noise_model import NoiseModel

EVAL_TIMES_LITERAL = Literal["Full", "Minimal", "Final"]

StateType = TypeVar("StateType", bound=State)


@dataclass(frozen=True)
class BackendConfig:
"""The base backend configuration.
"""The base backend configuration."""

Attributes:
backend_options: A dictionary of backend specific options.
_backend_options: dict[str, Any]

def __init__(self, **backend_options: Any) -> None:
"""Initializes the backend config."""
cls_name = self.__class__.__name__
if invalid_kwargs := (
set(backend_options)
- (self._expected_kwargs() | {"backend_options"})
):
warnings.warn(
f"{cls_name!r} received unexpected keyword arguments: "
f"{invalid_kwargs}; only the following keyword "
f"arguments are expected: {self._expected_kwargs()}.",
stacklevel=2,
)
# Prevents potential issues with mutable arguments
self._backend_options = copy.deepcopy(backend_options)
if "backend_options" in backend_options:
with warnings.catch_warnings():
warnings.filterwarnings("always")
warnings.warn(
f"The 'backend_options' argument of {cls_name!r} "
"has been deprecated. Please provide the options "
f"as keyword arguments directly to '{cls_name}()'.",
DeprecationWarning,
stacklevel=2,
)
self._backend_options.update(backend_options["backend_options"])

def _expected_kwargs(self) -> set[str]:
return set()

def __getattr__(self, name: str) -> Any:
if (
# Needed to avoid recursion error
"_backend_options" in self.__dict__
and name in self._backend_options
):
return self._backend_options[name]
raise AttributeError(f"{name!r} has not been passed to {self!r}.")


class EmulationConfig(BackendConfig, Generic[StateType]):
"""Configures an emulation on a backend.

Args:
observables: A sequence of observables to compute at specific
evaluation times. The observables without specified evaluation
times will use this configuration's 'default_evaluation_times'.
default_evaluation_times: The default times at which observables
are computed. Can be a sequence of relative times between 0
(the start of the sequence) and 1 (the end of the sequence).
Can also be specified as "Full", in which case every step in the
emulation will also be an evaluation times.
initial_state: The initial state from which emulation starts. If
specified, the state type needs to be compatible with the emulator
backend. If left undefined, defaults to starting with all qudits
in the ground state.
with_modulation: Whether to emulate the sequence with the programmed
input or the expected output.
interaction_matrix: An optional interaction matrix to replace the
interaction terms in the Hamiltonian. For an N-qudit system,
must be an NxN symmetric matrix where entry (i, j) dictates
the interaction coefficient between qudits i and j, ie it replaces
the C_n/r_{ij}^n term.
prefer_device_noise_model: If the sequence's device has a default noise
model, this option signals the backend to prefer it over the noise
model given with this configuration.
noise_model: An optional noise model to emulate the sequence with.
Ignored if the sequence's device has default noise model and
`prefer_device_noise_model=True`.
"""

backend_options: dict[str, Any] = field(default_factory=dict)
observables: Sequence[Observable]
default_evaluation_times: np.ndarray | Literal["Full"]
initial_state: StateType | None
with_modulation: bool
interaction_matrix: pm.AbstractArray | None
prefer_device_noise_model: bool
noise_model: NoiseModel

def __init__(
self,
*,
observables: Sequence[Observable] = (),
# Default evaluation times for observables that don't specify one
default_evaluation_times: Sequence[SupportsFloat] | Literal["Full"] = (
1.0,
),
initial_state: StateType | None = None, # Default is ggg...
with_modulation: bool = False,
interaction_matrix: ArrayLike | None = None,
prefer_device_noise_model: bool = False,
noise_model: NoiseModel = NoiseModel(),
**backend_options: Any,
) -> None:
"""Initializes the EmulationConfig."""
for obs in observables:
if not isinstance(obs, Observable):
raise TypeError(
"All entries in 'observables' must be instances of "
f"Observable. Instead, got instance of type {type(obs)}."
)

if default_evaluation_times != "Full":
eval_times_arr = np.array(default_evaluation_times, dtype=float)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this a np.array but below you use AbstractArray?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean for interaction_matrix? I use AbstractArray because I expect people might want to differentiate against it

if np.any((eval_times_arr < 0.0) | (eval_times_arr > 1.0)):
raise ValueError(
"All evaluation times must be between 0. and 1."
)
default_evaluation_times = cast(Sequence[float], eval_times_arr)

if initial_state is not None and not isinstance(initial_state, State):
raise TypeError(
"When defined, 'initial_state' must be an instance of State;"
f" got object of type {type(initial_state)} instead."
)

if interaction_matrix is not None:
interaction_matrix = pm.AbstractArray(interaction_matrix)
_shape = interaction_matrix.shape
if len(_shape) != 2 or _shape[0] != _shape[1]:
raise ValueError(
"'interaction_matrix' must be a square matrix. Instead, "
f"an array of shape {_shape} was given."
)
if (
initial_state is not None
and _shape[0] != initial_state.n_qudits
):
raise ValueError(
f"The received interaction matrix of shape {_shape} is "
"incompatible with the received initial state of "
f"{initial_state.n_qudits} qudits."
)

if not isinstance(noise_model, NoiseModel):
raise TypeError(
"'noise_model' must be a NoiseModel instance,"
f" not {type(noise_model)}."
)

super().__init__(
observables=tuple(observables),
default_evaluation_times=default_evaluation_times,
initial_state=initial_state,
with_modulation=bool(with_modulation),
interaction_matrix=interaction_matrix,
prefer_device_noise_model=bool(prefer_device_noise_model),
noise_model=noise_model,
**backend_options,
)

def _expected_kwargs(self) -> set[str]:
return super()._expected_kwargs() | {
"observables",
"default_evaluation_times",
"initial_state",
"with_modulation",
"interaction_matrix",
"prefer_device_noise_model",
"noise_model",
}

def is_evaluation_time(self, t: float, tol: float = 1e-6) -> bool:
"""Assesses whether a relative time is an evaluation time."""
return 0.0 <= t <= 1.0 and (
self.default_evaluation_times == "Full"
or self.is_time_in_evaluation_times(
t, self.default_evaluation_times, tol=tol
)
)

@dataclass(frozen=True)
@staticmethod
def is_time_in_evaluation_times(
t: float, evaluation_times: ArrayLike, tol: float = 1e-6
) -> bool:
"""Checks if a time is within a collection of evaluation times."""
return bool(
np.any(np.abs(np.array(evaluation_times, dtype=float) - t) <= tol)
)


# Legacy class


@dataclass
class EmulatorConfig(BackendConfig):
"""The configuration for emulator backends.

Expand Down Expand Up @@ -74,6 +269,7 @@ class EmulatorConfig(BackendConfig):
`prefer_device_noise_model=True`.
"""

backend_options: dict[str, Any] = field(default_factory=dict)
sampling_rate: float = 1.0
evaluation_times: float | Sequence[float] | EVAL_TIMES_LITERAL = "Full"
initial_state: Literal["all-ground"] | Sequence[complex] = "all-ground"
Expand All @@ -82,6 +278,7 @@ class EmulatorConfig(BackendConfig):
noise_model: NoiseModel = field(default_factory=NoiseModel)

def __post_init__(self) -> None:
# TODO: Deprecate
if not (0 < self.sampling_rate <= 1.0):
raise ValueError(
"The sampling rate (`sampling_rate` = "
Expand Down
Loading
Loading