diff --git a/pulser-core/pulser/json/abstract_repr/deserializer.py b/pulser-core/pulser/json/abstract_repr/deserializer.py index e76f1a90..ff6d1b78 100644 --- a/pulser-core/pulser/json/abstract_repr/deserializer.py +++ b/pulser-core/pulser/json/abstract_repr/deserializer.py @@ -410,7 +410,7 @@ def _deserialize_register3d( def _deserialize_noise_model(noise_model_obj: dict[str, Any]) -> NoiseModel: - def convert_complex(obj: list | tuple) -> list: + def convert_complex(obj: Any) -> Any: if isinstance(obj, (list, tuple)): return [convert_complex(e) for e in obj] elif isinstance(obj, dict): @@ -423,11 +423,15 @@ def convert_complex(obj: list | tuple) -> list: for rate, oper in noise_model_obj.pop("eff_noise"): eff_noise_rates.append(rate) eff_noise_opers.append(convert_complex(oper)) - return pulser.NoiseModel( + + noise_types = noise_model_obj.pop("noise_types") + noise_model = pulser.NoiseModel( **noise_model_obj, eff_noise_rates=tuple(eff_noise_rates), eff_noise_opers=tuple(eff_noise_opers), ) + assert set(noise_model.noise_types) == set(noise_types) + return noise_model def _deserialize_device_object(obj: dict[str, Any]) -> Device | VirtualDevice: diff --git a/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json index 6fbaecee..7da4afad 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json @@ -66,7 +66,10 @@ "type": "number" }, "laser_waist": { - "type": "number" + "type": [ + "number", + "null" + ] }, "noise_types": { "items": { @@ -84,10 +87,16 @@ "type": "number" }, "runs": { - "type": "number" + "type": [ + "number", + "null" + ] }, "samples_per_run": { - "type": "number" + "type": [ + "number", + "null" + ] }, "state_prep_error": { "type": "number" diff --git a/pulser-core/pulser/noise_model.py b/pulser-core/pulser/noise_model.py index 67451813..349b2a59 100644 --- a/pulser-core/pulser/noise_model.py +++ b/pulser-core/pulser/noise_model.py @@ -15,8 +15,10 @@ from __future__ import annotations import json -from dataclasses import asdict, dataclass, field, fields -from typing import Any, Literal, get_args +import warnings +from collections.abc import Collection, Sequence +from dataclasses import asdict, dataclass, fields +from typing import Any, Literal, Union, cast, get_args import numpy as np from numpy.typing import ArrayLike @@ -27,7 +29,7 @@ __all__ = ["NoiseModel"] -NOISE_TYPES = Literal[ +NoiseTypes = Literal[ "doppler", "amplitude", "SPAM", @@ -37,51 +39,94 @@ "eff_noise", ] - -@dataclass(frozen=True) +_NOISE_TYPE_PARAMS: dict[NoiseTypes, tuple[str, ...]] = { + "doppler": ("temperature",), + "amplitude": ("laser_waist", "amp_sigma"), + "SPAM": ("p_false_pos", "p_false_neg", "state_prep_error"), + "dephasing": ("dephasing_rate", "hyperfine_dephasing_rate"), + "relaxation": ("relaxation_rate",), + "depolarizing": ("depolarizing_rate",), + "eff_noise": ("eff_noise_rates", "eff_noise_opers"), +} + +_PARAM_TO_NOISE_TYPE: dict[str, NoiseTypes] = { + param: noise_type + for noise_type, params in _NOISE_TYPE_PARAMS.items() + for param in params +} + +# Parameter characterization + +_POSITIVE = { + "dephasing_rate", + "hyperfine_dephasing_rate", + "relaxation_rate", + "depolarizing_rate", + "temperature", +} +_STRICT_POSITIVE = { + "runs", + "samples_per_run", + "laser_waist", +} +_PROBABILITY_LIKE = { + "state_prep_error", + "p_false_pos", + "p_false_neg", + "amp_sigma", +} + +_LEGACY_DEFAULTS = { + "runs": 15, + "samples_per_run": 5, + "state_prep_error": 0.005, + "p_false_pos": 0.01, + "p_false_neg": 0.05, + "temperature": 50.0, + "laser_waist": 175.0, + "amp_sigma": 5e-2, + "relaxation_rate": 0.01, + "dephasing_rate": 0.05, + "hyperfine_dephasing_rate": 1e-3, + "depolarizing_rate": 0.05, +} + + +@dataclass(init=False, repr=False, frozen=True) 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 values which - are only taken into account when the related noise type is selected. + Supported noise types: + + - **relaxation**: Noise due to a decay from the Rydberg to + the ground state (parametrized by ``relaxation_rate``), + commonly characterized experimentally by the T1 time. + - **dephasing**: Random phase (Z) flip (parametrized + by ``dephasing_rate``), commonly characterized + experimentally by the T2* time. + - **depolarizing**: Quantum noise where the state is + turned into the maximally mixed state with rate + ``depolarizing_rate``. While it does not describe a + physical phenomenon, it is a commonly used tool to test + the system under a uniform combination of phase flip (Z) and + bit flip (X) errors. + - **eff_noise**: General effective noise channel defined by the + set of collapse operators ``eff_noise_opers`` and their + corresponding rates ``eff_noise_rates``. + - **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``. Args: - noise_types: Noise types to include in the emulation. - Available options: - - - "relaxation": Noise due to a decay from the Rydberg to - the ground state (parametrized by `relaxation_rate`), commonly - characterized experimentally by the T1 time. - - - "dephasing": Random phase (Z) flip (parametrized - by `dephasing_rate`), commonly characterized experimentally - by the T2* time. - - - "depolarizing": Quantum noise where the state is - turned into the maximally mixed state with rate - `depolarizing_rate`. While it does not describe a physical - phenomenon, it is a commonly used tool to test the system - under a uniform combination of phase flip (Z) and - bit flip (X) errors. - - - "eff_noise": General effective noise channel defined by - the set of collapse operators `eff_noise_opers` - and the corresponding rates distribution - `eff_noise_rates`. - - - "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`. - + noise_types: *Deprecated, simply define the approriate parameters + instead*. Noise types to include in the emulation. Defining + noise in this way will rely on legacy defaults for the relevant + parameters whenever a custom value is not provided. runs: When reconstructing the Hamiltonian from random noise is necessary, this determines how many times that happens. Not to be confused with the number of times the resulting @@ -113,115 +158,204 @@ class NoiseModel: eff_noise_opers: The operators for the effective noise model. """ - 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 - relaxation_rate: float = 0.01 - dephasing_rate: float = 0.05 - hyperfine_dephasing_rate: float = 1e-3 - depolarizing_rate: float = 0.05 - eff_noise_rates: tuple[float, ...] = field(default_factory=tuple) - eff_noise_opers: tuple[ArrayLike, ...] = field(default_factory=tuple) - - def __post_init__(self) -> None: - positive = { - "dephasing_rate", - "hyperfine_dephasing_rate", - "relaxation_rate", - "depolarizing_rate", - } - strict_positive = { - "runs", - "samples_per_run", - "temperature", - "laser_waist", - } - probability_like = { - "state_prep_error", - "p_false_pos", - "p_false_neg", - "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 positive: - is_valid = value is None or value >= 0 - comp = "None or greater than or equal to zero" - 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}.") + noise_types: tuple[NoiseTypes, ...] + runs: int | None + samples_per_run: int | None + state_prep_error: float + p_false_pos: float + p_false_neg: float + temperature: float + laser_waist: float | None + amp_sigma: float + relaxation_rate: float + dephasing_rate: float + hyperfine_dephasing_rate: float + depolarizing_rate: float + eff_noise_rates: tuple[float, ...] + eff_noise_opers: tuple[ArrayLike, ...] + + def __init__( + self, + noise_types: tuple[NoiseTypes, ...] | None = None, + runs: int | None = None, + samples_per_run: int | None = None, + state_prep_error: float | None = None, + p_false_pos: float | None = None, + p_false_neg: float | None = None, + temperature: float | None = None, + laser_waist: float | None = None, + amp_sigma: float | None = None, + relaxation_rate: float | None = None, + dephasing_rate: float | None = None, + hyperfine_dephasing_rate: float | None = None, + depolarizing_rate: float | None = None, + eff_noise_rates: tuple[float, ...] = (), + eff_noise_opers: tuple[ArrayLike, ...] = (), + ) -> None: + """Initializes a noise model.""" def to_tuple(obj: tuple) -> tuple: if isinstance(obj, (tuple, list, np.ndarray)): obj = tuple(to_tuple(el) for el in obj) return obj - # Turn lists and arrays into tuples - for f in fields(self): - if f.name == "noise_types" or "eff_noise" in f.name: - object.__setattr__( - self, f.name, to_tuple(getattr(self, f.name)) + param_vals = dict( + runs=runs, + samples_per_run=samples_per_run, + state_prep_error=state_prep_error, + p_false_neg=p_false_neg, + p_false_pos=p_false_pos, + temperature=temperature, + laser_waist=laser_waist, + amp_sigma=amp_sigma, + relaxation_rate=relaxation_rate, + dephasing_rate=dephasing_rate, + hyperfine_dephasing_rate=hyperfine_dephasing_rate, + depolarizing_rate=depolarizing_rate, + eff_noise_rates=to_tuple(eff_noise_rates), + eff_noise_opers=to_tuple(eff_noise_opers), + ) + + if noise_types is not None: + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The explicit definition of noise types is deprecated; " + "doing so will use legacy default values for all relevant " + "parameters that are not given a custom value. Instead, " + "defining only the necessary parameters is recommended; " + "doing so (when the noise types are not explicitly given) " + "will disregard all undefined parameters.", + DeprecationWarning, + stacklevel=2, + ) + self._check_noise_types(noise_types) + for nt_ in noise_types: + for p_ in _NOISE_TYPE_PARAMS[nt_]: + # Replace undefined relevant params by the legacy default + if param_vals[p_] is None: + param_vals[p_] = _LEGACY_DEFAULTS[p_] + + true_noise_types: set[NoiseTypes] = { + _PARAM_TO_NOISE_TYPE[p_] + for p_ in param_vals + if param_vals[p_] and p_ in _PARAM_TO_NOISE_TYPE + } + + self._check_eff_noise( + cast(tuple, param_vals["eff_noise_rates"]), + cast(tuple, param_vals["eff_noise_opers"]), + "eff_noise" in (noise_types or true_noise_types), + ) + + # Get rid of unnecessary None's + for p_ in _POSITIVE | _PROBABILITY_LIKE: + param_vals[p_] = param_vals[p_] or 0.0 + + relevant_params = self._find_relevant_params( + true_noise_types, + cast(float, param_vals["state_prep_error"]), + cast(float, param_vals["amp_sigma"]), + cast(Union[float, None], param_vals["laser_waist"]), + ) + + if noise_types is not None: + if true_noise_types != set(noise_types): + raise ValueError( + "The explicit definition of noise types (deprecated) is" + " not compatible with the modification of unrelated noise " + "parameters. Defining only the relevant noise parameters " + "(without specifying the noise types) is recommended." ) + # Only now that we know the relevant_params can we determine if + # we need to use the legacy defaults for the run parameters (ie in + # case they were not provided by the user) + run_params_ = relevant_params & {"runs", "samples_per_run"} + for p_ in run_params_: + param_vals[p_] = param_vals[p_] or _LEGACY_DEFAULTS[p_] + + relevant_param_vals = { + p: param_vals[p] + for p in param_vals + if param_vals[p] is not None or (p in relevant_params) + } + self._validate_parameters(relevant_param_vals) - self._check_noise_types() - self._check_eff_noise() + object.__setattr__( + self, "noise_types", tuple(sorted(true_noise_types)) + ) + for param_, val_ in param_vals.items(): + object.__setattr__(self, param_, val_) + if val_ and param_ not in relevant_params: + warnings.warn( + f"{param_!r} is not used by any active noise type " + f"{self.noise_types}.", + stacklevel=2, + ) - def _check_noise_types(self) -> None: - for noise_type in self.noise_types: - if noise_type not in get_args(NOISE_TYPES): + @staticmethod + def _find_relevant_params( + noise_types: Collection[NoiseTypes], + state_prep_error: float, + amp_sigma: float, + laser_waist: float | None, + ) -> set[str]: + relevant_params: set[str] = set() + for nt_ in noise_types: + relevant_params.update(_NOISE_TYPE_PARAMS[nt_]) + if ( + nt_ == "doppler" + or (nt_ == "amplitude" and amp_sigma != 0.0) + or (nt_ == "SPAM" and state_prep_error != 0.0) + ): + relevant_params.update(("runs", "samples_per_run")) + # Disregard laser_waist when not defined + if laser_waist is None: + relevant_params.discard("laser_waist") + return relevant_params + + @staticmethod + def _check_noise_types(noise_types: Sequence[NoiseTypes]) -> None: + for noise_type in noise_types: + if noise_type not in get_args(NoiseTypes): raise ValueError( f"'{noise_type}' is not a valid noise type. " + "Valid noise types: " - + ", ".join(get_args(NOISE_TYPES)) + + ", ".join(get_args(NoiseTypes)) ) - def _check_eff_noise(self) -> None: - if len(self.eff_noise_opers) != len(self.eff_noise_rates): + @staticmethod + def _check_eff_noise( + eff_noise_rates: Sequence[float], + eff_noise_opers: Sequence[ArrayLike], + check_contents: bool, + ) -> None: + if len(eff_noise_opers) != len(eff_noise_rates): raise ValueError( - f"The operators list length({len(self.eff_noise_opers)}) " + f"The operators list length({len(eff_noise_opers)}) " "and rates list length" - f"({len(self.eff_noise_rates)}) must be equal." + f"({len(eff_noise_rates)}) must be equal." ) - for rate in self.eff_noise_rates: + for rate in eff_noise_rates: if not isinstance(rate, float): raise TypeError( "eff_noise_rates is a list of floats," f" it must not contain a {type(rate)}." ) - if "eff_noise" not in self.noise_types: - # Stop here if effective noise is not selected + if not check_contents: return - if not self.eff_noise_opers or not self.eff_noise_rates: + if not eff_noise_opers or not eff_noise_rates: raise ValueError( "The effective noise parameters have not been filled." ) - if np.any(np.array(self.eff_noise_rates) < 0): + if np.any(np.array(eff_noise_rates) < 0): raise ValueError("The provided rates must be greater than 0.") # Check the validity of operators - for op in self.eff_noise_opers: + for op in eff_noise_opers: # type checking try: operator = np.array(op, dtype=complex) @@ -237,6 +371,26 @@ def _check_eff_noise(self) -> None: f"Operator's shape must be (2,2) not {operator.shape}." ) + @staticmethod + def _validate_parameters(param_vals: dict[str, Any]) -> None: + for param in param_vals: + is_valid = True + value = param_vals[param] + if param in _POSITIVE: + is_valid = value >= 0 + comp = "greater than or equal to zero" + elif param in _STRICT_POSITIVE: + is_valid = value is not None and 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}.") + def _to_abstract_repr(self) -> dict[str, Any]: all_fields = asdict(self) eff_noise_rates = all_fields.pop("eff_noise_rates") @@ -244,6 +398,20 @@ def _to_abstract_repr(self) -> dict[str, Any]: all_fields["eff_noise"] = list(zip(eff_noise_rates, eff_noise_opers)) return all_fields + def __repr__(self) -> str: + relevant_params = self._find_relevant_params( + self.noise_types, + self.state_prep_error, + self.amp_sigma, + self.laser_waist, + ) + relevant_params.add("noise_types") + params_list = [] + for f in fields(self): + if f.name in relevant_params: + params_list.append(f"{f.name}={getattr(self, f.name)!r}") + return f"{self.__class__.__name__}({', '.join(params_list)})" + def to_abstract_repr(self) -> str: """Serializes the noise model into an abstract JSON object.""" abstr_str = json.dumps(self, cls=AbstractReprEncoder) diff --git a/pulser-simulation/pulser_simulation/hamiltonian.py b/pulser-simulation/pulser_simulation/hamiltonian.py index 1dd17b7e..aa39cf6d 100644 --- a/pulser-simulation/pulser_simulation/hamiltonian.py +++ b/pulser-simulation/pulser_simulation/hamiltonian.py @@ -218,10 +218,13 @@ def add_noise( # Gaussian beam loss in amplitude for global pulses only # Noise is drawn at random for each pulse if "amplitude" in self.config.noise_types and is_global_pulse: - position = self._qdict[qid] - r = np.linalg.norm(position) - w0 = self.config.laser_waist - noise_amp = noise_amp_base * np.exp(-((r / w0) ** 2)) + amp_fraction = 1.0 + if self.config.laser_waist is not None: + position = self._qdict[qid] + r = np.linalg.norm(position) + w0 = self.config.laser_waist + amp_fraction = np.exp(-((r / w0) ** 2)) + noise_amp = noise_amp_base * amp_fraction samples_dict[qid]["amp"][slot.ti : slot.tf] *= noise_amp if local_noises: @@ -307,10 +310,9 @@ def _update_noise(self) -> None: ) self._bad_atoms = dict(zip(self._qid_index, dist)) if "doppler" in self.config.noise_types: + temp = self.config.temperature * 1e-6 detune = np.random.normal( - 0, - doppler_sigma(self.config.temperature / 1e6), - size=len(self._qid_index), + 0, doppler_sigma(temp), size=len(self._qid_index) ) self._doppler_detune = dict(zip(self._qid_index, detune)) @@ -351,6 +353,12 @@ def _construct_hamiltonian(self, update: bool = True) -> None: Also builds qutip.Qobjs related to the Sequence if not built already, and refreshes potential noise parameters by drawing new at random. + Warning: + The refreshed noise parameters (when update=True) are only those + that change from shot to shot (ie doppler and state preparation). + Amplitude fluctuations change from pulse to pulse and are always + applied in `_extract_samples()`. + Args: update: Whether to update the noise parameters. """ diff --git a/pulser-simulation/pulser_simulation/simconfig.py b/pulser-simulation/pulser_simulation/simconfig.py index d0576766..3e8735c2 100644 --- a/pulser-simulation/pulser_simulation/simconfig.py +++ b/pulser-simulation/pulser_simulation/simconfig.py @@ -15,13 +15,13 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from math import sqrt from typing import Any, Optional, Tuple, Type, TypeVar, Union, cast import qutip -from pulser.noise_model import NOISE_TYPES, NoiseModel +from pulser.noise_model import _LEGACY_DEFAULTS, NoiseModel, NoiseTypes MASS = 1.45e-25 # kg KB = 1.38e-23 # J/K @@ -47,6 +47,14 @@ }, } +# Maps the noise model parameters with a different name in SimConfig +_DIFF_NOISE_PARAMS = { + "noise_types": "noise", + "state_prep_error": "eta", + "p_false_pos": "epsilon", + "p_false_neg": "epsilon_prime", +} + def doppler_sigma(temperature: float) -> float: """Standard deviation for Doppler shifting due to thermal motion. @@ -99,19 +107,21 @@ class SimConfig: solver_options: Options for the qutip solver. """ - noise: Union[NOISE_TYPES, tuple[NOISE_TYPES, ...]] = () - runs: int = 15 - samples_per_run: int = 5 - temperature: float = 50.0 - laser_waist: float = 175.0 - amp_sigma: float = 5e-2 - eta: float = 0.005 - epsilon: float = 0.01 - epsilon_prime: float = 0.05 - relaxation_rate: float = 0.01 - dephasing_rate: float = 0.05 - hyperfine_dephasing_rate: float = 1e-3 - depolarizing_rate: float = 0.05 + noise: Union[NoiseTypes, tuple[NoiseTypes, ...]] = () + runs: int = cast(int, _LEGACY_DEFAULTS["runs"]) + samples_per_run: int = cast(int, _LEGACY_DEFAULTS["samples_per_run"]) + temperature: float = _LEGACY_DEFAULTS["temperature"] + laser_waist: float = _LEGACY_DEFAULTS["laser_waist"] + amp_sigma: float = _LEGACY_DEFAULTS["amp_sigma"] + eta: float = _LEGACY_DEFAULTS["state_prep_error"] + epsilon: float = _LEGACY_DEFAULTS["p_false_pos"] + epsilon_prime: float = _LEGACY_DEFAULTS["p_false_neg"] + relaxation_rate: float = _LEGACY_DEFAULTS["relaxation_rate"] + dephasing_rate: float = _LEGACY_DEFAULTS["dephasing_rate"] + hyperfine_dephasing_rate: float = _LEGACY_DEFAULTS[ + "hyperfine_dephasing_rate" + ] + depolarizing_rate: float = _LEGACY_DEFAULTS["depolarizing_rate"] eff_noise_rates: 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 @@ -119,43 +129,33 @@ class SimConfig: @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_rate=noise_model.dephasing_rate, - hyperfine_dephasing_rate=noise_model.hyperfine_dephasing_rate, - relaxation_rate=noise_model.relaxation_rate, - depolarizing_rate=noise_model.depolarizing_rate, - eff_noise_rates=list(noise_model.eff_noise_rates), - eff_noise_opers=list(map(qutip.Qobj, noise_model.eff_noise_opers)), + kwargs: dict[str, Any] = dict(noise=noise_model.noise_types) + relevant_params = NoiseModel._find_relevant_params( + noise_model.noise_types, + noise_model.state_prep_error, + noise_model.amp_sigma, + noise_model.laser_waist, ) + for param in relevant_params: + kwargs[_DIFF_NOISE_PARAMS.get(param, param)] = getattr( + noise_model, param + ) + return cls(**kwargs) 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_rate=self.dephasing_rate, - hyperfine_dephasing_rate=self.hyperfine_dephasing_rate, - relaxation_rate=self.relaxation_rate, - depolarizing_rate=self.depolarizing_rate, - eff_noise_rates=tuple(self.eff_noise_rates), - eff_noise_opers=tuple(op.full() for op in self.eff_noise_opers), + relevant_params = NoiseModel._find_relevant_params( + cast(Tuple[NoiseTypes, ...], self.noise), + self.eta, + self.amp_sigma, + self.laser_waist, ) + kwargs = {} + for param in relevant_params: + kwargs[param] = getattr(self, _DIFF_NOISE_PARAMS.get(param, param)) + if "temperature" in kwargs: + kwargs["temperature"] *= 1e6 # Converts back to µK + return NoiseModel(**kwargs) def __post_init__(self) -> None: # only one noise was given as argument : convert it to a tuple @@ -169,13 +169,12 @@ def __post_init__(self) -> None: ) self._change_attribute("temperature", self.temperature / 1e6) - # Kept to show error messages with the right parameter names + NoiseModel._check_noise_types(cast(Tuple[NoiseTypes], self.noise)) self._check_spam_dict() - - self._check_eff_noise_opers_type() - - # Runs the noise model checks - self.to_noise_model() + self._check_eff_noise() + NoiseModel._validate_parameters( + {f.name: getattr(self, f.name) for f in fields(self)} + ) @property def spam_dict(self) -> dict[str, float]: @@ -240,7 +239,7 @@ def _check_spam_dict(self) -> None: def _change_attribute(self, attr_name: str, new_value: Any) -> None: object.__setattr__(self, attr_name, new_value) - def _check_eff_noise_opers_type(self) -> None: + def _check_eff_noise(self) -> None: # Check the validity of operators for operator in self.eff_noise_opers: # type checking @@ -250,6 +249,11 @@ def _check_eff_noise_opers_type(self) -> None: raise TypeError( "Operators are supposed to be of Qutip type 'oper'." ) + NoiseModel._check_eff_noise( + self.eff_noise_rates, + self.eff_noise_opers, + "eff_noise" in self.noise, + ) @property def supported_noises(self) -> dict: diff --git a/pulser-simulation/pulser_simulation/simulation.py b/pulser-simulation/pulser_simulation/simulation.py index aa28123e..4a718537 100644 --- a/pulser-simulation/pulser_simulation/simulation.py +++ b/pulser-simulation/pulser_simulation/simulation.py @@ -250,33 +250,16 @@ 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._hamiltonian.config) - # Begin populating with added noise parameters: - param_dict["noise_types"] = tuple(new_noise_set) - if "SPAM" in diff_noise_set: - param_dict["state_prep_error"] = noise_model.state_prep_error - param_dict["p_false_pos"] = noise_model.p_false_pos - param_dict["p_false_neg"] = noise_model.p_false_neg - if "doppler" in diff_noise_set: - param_dict["temperature"] = noise_model.temperature - if "amplitude" in diff_noise_set: - param_dict["laser_waist"] = noise_model.laser_waist - param_dict["amp_sigma"] = noise_model.amp_sigma - if "dephasing" in diff_noise_set: - param_dict["dephasing_rate"] = noise_model.dephasing_rate - param_dict["hyperfine_dephasing_rate"] = ( - noise_model.hyperfine_dephasing_rate - ) - if "relaxation" in diff_noise_set: - param_dict["relaxation_rate"] = noise_model.relaxation_rate - if "depolarizing" in diff_noise_set: - param_dict["depolarizing_rate"] = noise_model.depolarizing_rate - if "eff_noise" in diff_noise_set: - param_dict["eff_noise_opers"] = noise_model.eff_noise_opers - param_dict["eff_noise_rates"] = noise_model.eff_noise_rates - # update runs: - param_dict["runs"] = noise_model.runs - param_dict["samples_per_run"] = noise_model.samples_per_run + relevant_params = NoiseModel._find_relevant_params( + diff_noise_set, + noise_model.state_prep_error, + noise_model.amp_sigma, + noise_model.laser_waist, + ) + for param in relevant_params: + param_dict[param] = getattr(noise_model, param) # set config with the new parameters: + param_dict.pop("noise_types") self._hamiltonian.set_config(NoiseModel(**param_dict)) def show_config(self, solver_options: bool = False) -> None: @@ -546,6 +529,7 @@ def _run_solver() -> CoherentResults: raise ValueError("`progress_bar` must be a bool.") if ( + # TODO: Check that the relevant dephasing parameter is > 0. "dephasing" in self.config.noise or "relaxation" in self.config.noise or "depolarizing" in self.config.noise @@ -587,7 +571,18 @@ def _run_solver() -> CoherentResults: # Check if noises ask for averaging over multiple runs: if set(self.config.noise).issubset( - {"dephasing", "relaxation", "SPAM", "depolarizing", "eff_noise"} + { + "dephasing", + "relaxation", + "SPAM", + "depolarizing", + "eff_noise", + "amplitude", + } + ) and ( + # If amplitude is in noise, not resampling needs amp_sigma=0. + "amplitude" not in self.config.noise + or self.config.amp_sigma == 0.0 ): # If there is "SPAM", the preparation errors must be zero if "SPAM" not in self.config.noise or self.config.eta == 0: diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 3608bb76..3cefbb09 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -78,10 +78,8 @@ replace(Chadoq2.dmm_objects[0], total_bottom_detuning=-2000), ), default_noise_model=NoiseModel( - noise_types=("SPAM", "relaxation", "dephasing"), p_false_pos=0.02, p_false_neg=0.01, - state_prep_error=0.0, # To avoid Hamiltonian resampling relaxation_rate=0.01, dephasing_rate=0.2, ), @@ -177,8 +175,9 @@ def test_register(reg: Register | Register3D): "noise_model", [ NoiseModel(), + NoiseModel(laser_waist=100), + NoiseModel(temperature=100, runs=10, samples_per_run=1), NoiseModel( - noise_types=("eff_noise",), eff_noise_rates=(0.1,), eff_noise_opers=(((0, -1j), (1j, 0)),), ), diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index c466caef..a5e41175 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -11,22 +11,90 @@ # 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 dataclasses +import re + import numpy as np import pytest -from pulser.noise_model import NoiseModel +from pulser.noise_model import ( + _NOISE_TYPE_PARAMS, + _PARAM_TO_NOISE_TYPE, + NoiseModel, +) + + +def test_constants(): + # Recreate _PARAM_TO_NOISE_TYPE and check it matches + params_dict = {} + for noise_type, params in _NOISE_TYPE_PARAMS.items(): + for p in params: + assert p not in params_dict + params_dict[p] = noise_type + assert params_dict == _PARAM_TO_NOISE_TYPE 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( + "params, noise_types", + [ + ({"p_false_pos", "dephasing_rate"}, {"SPAM", "dephasing"}), + ( + { + "state_prep_error", + "relaxation_rate", + "runs", + "samples_per_run", + }, + {"SPAM", "relaxation"}, + ), + ( + { + "temperature", + "depolarizing_rate", + "runs", + "samples_per_run", + }, + {"doppler", "depolarizing"}, + ), + ( + {"amp_sigma", "runs", "samples_per_run"}, + {"amplitude"}, + ), + ( + {"laser_waist", "hyperfine_dephasing_rate"}, + {"amplitude", "dephasing"}, + ), + ], + ) + def test_init(self, params, noise_types): + noise_model = NoiseModel(**{p: 1.0 for p in params}) + assert set(noise_model.noise_types) == noise_types + relevant_params = NoiseModel._find_relevant_params( + noise_types, + noise_model.state_prep_error, + noise_model.amp_sigma, + noise_model.laser_waist, + ) + assert all(getattr(noise_model, p) == 1.0 for p in params) + assert all( + not getattr(noise_model, p) for p in relevant_params - params + ) + + @pytest.mark.parametrize( + "noise_param", ["relaxation_rate", "p_false_neg", "laser_waist"] + ) + @pytest.mark.parametrize("unused_param", ["runs", "samples_per_run"]) + def test_unused_params(self, unused_param, noise_param): + with pytest.warns(UserWarning, match=f"'{unused_param}' is not used"): + NoiseModel(**{unused_param: 100, noise_param: 1.0}) @pytest.mark.parametrize( "param", - ["runs", "samples_per_run", "temperature", "laser_waist"], + ["runs", "samples_per_run", "laser_waist"], ) def test_init_strict_pos(self, param): with pytest.raises( @@ -34,45 +102,70 @@ def test_init_strict_pos(self, param): ): NoiseModel(**{param: 0}) - @pytest.mark.parametrize("value", [-1e-9, 0.2, 1.0001]) + @pytest.mark.parametrize("value", [-1e-9, 0.0, 0.2, 1.0001]) @pytest.mark.parametrize( - "param", + "param, noise", [ - "dephasing_rate", - "hyperfine_dephasing_rate", - "relaxation_rate", - "depolarizing_rate", + ("dephasing_rate", "dephasing"), + ("hyperfine_dephasing_rate", "dephasing"), + ("relaxation_rate", "relaxation"), + ("depolarizing_rate", "depolarizing"), + ("temperature", "doppler"), ], ) - def test_init_rate_like(self, param, value): + def test_init_rate_like(self, param, noise, value): + kwargs = {param: value} + if param == "temperature" and value != 0: + kwargs.update(dict(runs=1, samples_per_run=1)) if value < 0: with pytest.raises( ValueError, - match=f"'{param}' must be None or greater " - f"than or equal to zero, not {value}.", + match=f"'{param}' must be greater than " + f"or equal to zero, not {value}.", ): - NoiseModel(**{param: value}) + NoiseModel(**kwargs) else: - noise_model = NoiseModel(**{param: value}) + noise_model = NoiseModel(**kwargs) assert getattr(noise_model, param) == value + if value > 0: + assert noise_model.noise_types == (noise,) + else: + assert noise_model.noise_types == () - @pytest.mark.parametrize("value", [-1e-9, 1.0001]) + @pytest.mark.parametrize("value", [-1e-9, 0.0, 0.5, 1.0, 1.0001]) @pytest.mark.parametrize( - "param", + "param, noise", [ - "state_prep_error", - "p_false_pos", - "p_false_neg", - "amp_sigma", + ("state_prep_error", "SPAM"), + ("p_false_pos", "SPAM"), + ("p_false_neg", "SPAM"), + ("amp_sigma", "amplitude"), ], ) - def test_init_prob_like(self, param, value): + def test_init_prob_like(self, param, noise, value): + if 0 <= value <= 1: + kwargs = {param: value} + if value > 0 and param in ("amp_sigma", "state_prep_error"): + kwargs.update(dict(runs=1, samples_per_run=1)) + noise_model = NoiseModel(**kwargs) + assert getattr(noise_model, param) == value + if value > 0: + assert noise_model.noise_types == (noise,) + else: + assert noise_model.noise_types == () + return 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}) + NoiseModel( + # Define the strict positive quantities first so that their + # absence doesn't trigger their own errors + runs=1, + samples_per_run=1, + **{param: value}, + ) @pytest.fixture def matrices(self): @@ -90,57 +183,187 @@ def test_eff_noise_rates(self, matrices): ValueError, match="The provided rates must be greater than 0." ): NoiseModel( - noise_types=("eff_noise",), eff_noise_opers=[matrices["I"], matrices["X"]], eff_noise_rates=[-1.0, 0.5], ) def test_eff_noise_opers(self, matrices): with pytest.raises(ValueError, match="The operators list length"): - NoiseModel(noise_types=("eff_noise",), eff_noise_rates=[1.0]) + NoiseModel(eff_noise_rates=[1.0]) with pytest.raises( TypeError, match="eff_noise_rates is a list of floats" ): NoiseModel( - noise_types=("eff_noise",), eff_noise_rates=["0.1"], eff_noise_opers=[np.eye(2)], ) - with pytest.raises( - ValueError, - match="The effective noise parameters have not been filled.", - ): - NoiseModel(noise_types=("eff_noise",)) with pytest.raises(TypeError, match="not castable to a Numpy array"): NoiseModel( - noise_types=("eff_noise",), eff_noise_rates=[2.0], eff_noise_opers=[{(1.0, 0), (0.0, -1)}], ) with pytest.raises(ValueError, match="is not a 2D array."): NoiseModel( - noise_types=("eff_noise",), eff_noise_opers=[2.0], eff_noise_rates=[1.0], ) with pytest.raises(NotImplementedError, match="Operator's shape"): NoiseModel( - noise_types=("eff_noise",), eff_noise_opers=[matrices["I3"]], eff_noise_rates=[1.0], ) def test_eq(self, matrices): final_fields = dict( - noise_types=("SPAM", "eff_noise"), + p_false_pos=0.1, eff_noise_rates=(0.1, 0.4), eff_noise_opers=(((0, 1), (1, 0)), ((0, -1j), (1j, 0))), ) noise_model = NoiseModel( - noise_types=["SPAM", "eff_noise"], + p_false_pos=0.1, eff_noise_rates=[0.1, 0.4], eff_noise_opers=[matrices["X"], matrices["Y"]], ) assert noise_model == NoiseModel(**final_fields) + assert set(noise_model.noise_types) == {"SPAM", "eff_noise"} for param in final_fields: assert final_fields[param] == getattr(noise_model, param) + + def test_relevant_params(self): + assert NoiseModel._find_relevant_params({"SPAM"}, 0.0, 0.5, 100) == { + "state_prep_error", + "p_false_pos", + "p_false_neg", + } + assert NoiseModel._find_relevant_params({"SPAM"}, 0.1, 0.5, 100) == { + "state_prep_error", + "p_false_pos", + "p_false_neg", + "runs", + "samples_per_run", + } + + assert NoiseModel._find_relevant_params( + {"doppler"}, 0.0, 0.0, None + ) == {"temperature", "runs", "samples_per_run"} + + assert NoiseModel._find_relevant_params( + {"amplitude"}, 0.0, 1.0, None + ) == {"amp_sigma", "runs", "samples_per_run"} + assert NoiseModel._find_relevant_params( + {"amplitude"}, 0.0, 0.0, 100.0 + ) == {"amp_sigma", "laser_waist"} + assert NoiseModel._find_relevant_params( + {"amplitude"}, 0.0, 0.5, 100.0 + ) == {"amp_sigma", "laser_waist", "runs", "samples_per_run"} + + assert NoiseModel._find_relevant_params( + {"dephasing"}, 0.0, 0.0, None + ) == {"dephasing_rate", "hyperfine_dephasing_rate"} + assert NoiseModel._find_relevant_params( + {"relaxation"}, 0.0, 0.0, None + ) == {"relaxation_rate"} + assert NoiseModel._find_relevant_params( + {"depolarizing"}, 0.0, 0.0, None + ) == {"depolarizing_rate"} + assert NoiseModel._find_relevant_params( + {"eff_noise"}, 0.0, 0.0, None + ) == {"eff_noise_rates", "eff_noise_opers"} + + def test_repr(self): + assert repr(NoiseModel()) == "NoiseModel(noise_types=())" + assert ( + repr(NoiseModel(p_false_pos=0.1, relaxation_rate=0.2)) + == "NoiseModel(noise_types=('SPAM', 'relaxation'), " + "state_prep_error=0.0, p_false_pos=0.1, p_false_neg=0.0, " + "relaxation_rate=0.2)" + ) + assert ( + repr(NoiseModel(hyperfine_dephasing_rate=0.2)) + == "NoiseModel(noise_types=('dephasing',), " + "dephasing_rate=0.0, hyperfine_dephasing_rate=0.2)" + ) + assert ( + repr(NoiseModel(amp_sigma=0.3, runs=100, samples_per_run=1)) + == "NoiseModel(noise_types=('amplitude',), " + "runs=100, samples_per_run=1, amp_sigma=0.3)" + ) + assert ( + repr(NoiseModel(laser_waist=100.0)) + == "NoiseModel(noise_types=('amplitude',), " + "laser_waist=100.0, amp_sigma=0.0)" + ) + + +class TestLegacyNoiseModel: + def test_noise_type_errors(self): + with pytest.raises( + ValueError, match="'bad_noise' is not a valid noise type." + ): + with pytest.deprecated_call(): + NoiseModel(noise_types=("bad_noise",)) + + with pytest.raises( + ValueError, + match="The effective noise parameters have not been filled.", + ): + with pytest.deprecated_call(): + NoiseModel(noise_types=("eff_noise",)) + + with pytest.raises( + ValueError, + match=re.escape( + "The explicit definition of noise types (deprecated) is" + " not compatible with the modification of unrelated noise " + "parameters" + ), + ): + with pytest.deprecated_call(): + NoiseModel(noise_types=("SPAM",), laser_waist=100.0) + + @pytest.mark.parametrize( + "noise_type", ["SPAM", "doppler", "amplitude", "dephasing"] + ) + def test_legacy_init(self, noise_type): + expected_relevant_params = dict( + SPAM={ + "state_prep_error", + "p_false_pos", + "p_false_neg", + "runs", + "samples_per_run", + }, + amplitude={"laser_waist", "amp_sigma", "runs", "samples_per_run"}, + doppler={"temperature", "runs", "samples_per_run"}, + dephasing={"dephasing_rate", "hyperfine_dephasing_rate"}, + ) + non_zero_param = tuple(expected_relevant_params[noise_type])[0] + + with pytest.warns( + DeprecationWarning, + match="The explicit definition of noise types is deprecated", + ): + noise_model = NoiseModel( + **{"noise_types": (noise_type,), non_zero_param: 1} + ) + + # Check that the parameter is not overwritten by the default + assert getattr(noise_model, non_zero_param) == 1 + + relevant_params = NoiseModel._find_relevant_params( + {noise_type}, + # These values don't matter, they just have to be > 0 + state_prep_error=0.1, + amp_sigma=0.5, + laser_waist=100.0, + ) + assert relevant_params == expected_relevant_params[noise_type] + + for f in dataclasses.fields(noise_model): + val = getattr(noise_model, f.name) + if f.name == "noise_types": + assert val == (noise_type,) + elif f.name in relevant_params: + assert val > 0.0 + else: + assert not val diff --git a/tests/test_qutip_backend.py b/tests/test_qutip_backend.py index 0214f00d..5e9dd48f 100644 --- a/tests/test_qutip_backend.py +++ b/tests/test_qutip_backend.py @@ -70,7 +70,13 @@ def test_qutip_backend(sequence): def test_with_default_noise(sequence): - spam_noise = pulser.NoiseModel(noise_types=("SPAM",)) + spam_noise = pulser.NoiseModel( + p_false_pos=0.1, + p_false_neg=0.05, + state_prep_error=0.1, + runs=10, + samples_per_run=1, + ) new_device = dataclasses.replace( MockDevice, default_noise_model=spam_noise ) diff --git a/tests/test_simconfig.py b/tests/test_simconfig.py index 5a48ccfb..765257e4 100644 --- a/tests/test_simconfig.py +++ b/tests/test_simconfig.py @@ -49,7 +49,7 @@ def test_init(): and "100" in str_config and "Solver Options" in str_config ) - config = SimConfig(noise=("depolarizing", "relaxation")) + config = SimConfig(noise=("depolarizing", "relaxation", "doppler")) assert config.temperature == 5e-5 assert config.to_noise_model().temperature == 50 str_config = config.__str__(True) @@ -116,13 +116,13 @@ def test_eff_noise_opers(matrices): ) -def test_from_noise_model(): +def test_noise_model_conversion(): 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 + expected_simconfig = SimConfig( + noise="SPAM", epsilon=0.1, epsilon_prime=0.4, eta=0.0 ) + assert SimConfig.from_noise_model(noise_model) == expected_simconfig + assert expected_simconfig.to_noise_model() == noise_model diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 29bc0a62..5418a981 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -733,11 +733,7 @@ def test_noise_with_zero_epsilons(seq, matrices): 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 sim2.config.noise == () assert sim.run().sample_final_state() == sim2.run().sample_final_state() diff --git a/tutorials/advanced_features/Backends for Sequence Execution.ipynb b/tutorials/advanced_features/Backends for Sequence Execution.ipynb index ce5b6e53..b85ec320 100644 --- a/tutorials/advanced_features/Backends for Sequence Execution.ipynb +++ b/tutorials/advanced_features/Backends for Sequence Execution.ipynb @@ -190,10 +190,8 @@ "config = pulser.EmulatorConfig(\n", " sampling_rate=0.1,\n", " noise_model=pulser.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",