Source code for qtest.noise.models

"""Noise models for qtest.

Real quantum hardware is noisy, and a test that only ever runs on an ideal
simulator says nothing about how a circuit behaves under decoherence and gate
error. This module provides a small, SDK-agnostic :class:`NoiseModel` wrapper
plus a handful of ready-made constructors so tests can opt into noisy
simulation with a single argument::

    from qtest import assert_distribution_close
    from qtest.noise import depolarizing

    assert_distribution_close(qc, expected, noise_model=depolarizing(0.01))

Design
------
A :class:`NoiseModel` is a lightweight, immutable *description* of the noise to
apply — it stores a list of error *specs* and nothing else. The heavy Qiskit
Aer objects are built lazily in :meth:`NoiseModel.to_qiskit`, so constructing a
:class:`NoiseModel` (and importing this module) never requires ``qiskit-aer``.
The import is only triggered when a noisy assertion actually runs.

Models compose with ``+`` so several error channels can be layered::

    combined = depolarizing(0.01) + readout_error(0.02)
"""

from __future__ import annotations

import math
from typing import Any, Callable

_AER_MISSING_MSG = (
    "Noisy simulation requires qiskit-aer. Install it with:\n" "  pip install 'qtest[aer]'"
)

# Basis gates that single- and two-qubit errors are attached to. The lists are
# intentionally broad: Aer registers an error per gate *name*, harmlessly
# ignoring names absent from a given circuit, and transpilation targets the
# noise model's basis gates so errors fire on the gates that actually run.
_DEFAULT_1Q_GATES: tuple[str, ...] = (
    "id",
    "u",
    "u1",
    "u2",
    "u3",
    "p",
    "rx",
    "ry",
    "rz",
    "h",
    "x",
    "y",
    "z",
    "s",
    "sdg",
    "t",
    "tdg",
    "sx",
    "sxdg",
)
_DEFAULT_2Q_GATES: tuple[str, ...] = ("cx", "cz", "swap", "ecr", "rzz", "rxx")


def _check_probability(name: str, value: float) -> float:
    """Validate that *value* is a real probability in ``[0, 1]``; return it as float."""
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{name} must be a real number in [0, 1], got {value!r}")
    if math.isnan(value) or math.isinf(value):
        raise ValueError(f"{name} must be finite, got {value!r}")
    if not (0.0 <= float(value) <= 1.0):
        raise ValueError(f"{name} must lie in [0, 1], got {value}")
    return float(value)


def _check_positive(name: str, value: float) -> float:
    """Validate that *value* is a strictly positive real; return it as float."""
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{name} must be a positive real number, got {value!r}")
    if math.isnan(value) or math.isinf(value) or value <= 0.0:
        raise ValueError(f"{name} must be a positive finite number, got {value}")
    return float(value)


[docs] class NoiseModel: """An SDK-agnostic description of a noise channel to apply during simulation. Instances are immutable value objects: the recommended way to build one is via the module-level constructors (:func:`depolarizing`, :func:`bit_flip`, :func:`phase_flip`, :func:`thermal_relaxation`, :func:`readout_error`) and to layer channels with ``+``. Parameters ---------- specs Internal list of error-spec dicts. Prefer the public constructors. label Human-readable identifier used in assertion failure messages. """ def __init__(self, specs: list[dict[str, Any]] | None = None, label: str = "custom") -> None: self._specs: list[dict[str, Any]] = list(specs or []) self.label = label def __add__(self, other: object) -> NoiseModel: if not isinstance(other, NoiseModel): return NotImplemented return NoiseModel(self._specs + other._specs, label=f"{self.label}+{other.label}") def __repr__(self) -> str: return f"NoiseModel(label={self.label!r}, channels={len(self._specs)})" @property def is_empty(self) -> bool: """Whether this model carries no error channels (a no-op).""" return not self._specs
[docs] def to_qiskit(self) -> Any: """Build and return a :class:`qiskit_aer.noise.NoiseModel`. Raises ------ ImportError If ``qiskit-aer`` is not installed. """ try: from qiskit_aer.noise import ( NoiseModel as AerNoiseModel, ) from qiskit_aer.noise import ( ReadoutError, depolarizing_error, pauli_error, thermal_relaxation_error, ) except ImportError as exc: # pragma: no cover - exercised only without aer raise ImportError(_AER_MISSING_MSG) from exc nm = AerNoiseModel() for spec in self._specs: kind = spec["kind"] if kind == "depolarizing": p = spec["p"] nm.add_all_qubit_quantum_error(depolarizing_error(p, 1), list(_DEFAULT_1Q_GATES)) nm.add_all_qubit_quantum_error(depolarizing_error(p, 2), list(_DEFAULT_2Q_GATES)) elif kind == "bit_flip": p = spec["p"] err = pauli_error([("X", p), ("I", 1.0 - p)]) nm.add_all_qubit_quantum_error(err, list(_DEFAULT_1Q_GATES)) elif kind == "phase_flip": p = spec["p"] err = pauli_error([("Z", p), ("I", 1.0 - p)]) nm.add_all_qubit_quantum_error(err, list(_DEFAULT_1Q_GATES)) elif kind == "thermal_relaxation": err = thermal_relaxation_error(spec["t1"], spec["t2"], spec["time"]) nm.add_all_qubit_quantum_error(err, list(_DEFAULT_1Q_GATES)) elif kind == "readout": p01, p10 = spec["p01"], spec["p10"] ro = ReadoutError([[1.0 - p01, p01], [p10, 1.0 - p10]]) nm.add_all_qubit_readout_error(ro) else: # pragma: no cover - guarded by constructors raise ValueError(f"Unknown noise channel kind: {kind!r}") return nm
# --------------------------------------------------------------------------- # # Constructors # # --------------------------------------------------------------------------- #
[docs] def depolarizing(p: float) -> NoiseModel: """Depolarizing error of strength *p* on every gate. With probability *p* the qubit(s) acted on by a gate are replaced by the maximally mixed state. Applied as a 1-qubit channel to single-qubit gates and a 2-qubit channel to two-qubit gates. Parameters ---------- p Depolarizing probability in ``[0, 1]``. """ p = _check_probability("p", p) return NoiseModel([{"kind": "depolarizing", "p": p}], label=f"depolarizing(p={p:g})")
[docs] def bit_flip(p: float) -> NoiseModel: """Bit-flip (Pauli-X) error: apply X with probability *p* after 1-qubit gates.""" p = _check_probability("p", p) return NoiseModel([{"kind": "bit_flip", "p": p}], label=f"bit_flip(p={p:g})")
[docs] def phase_flip(p: float) -> NoiseModel: """Phase-flip (Pauli-Z) error: apply Z with probability *p* after 1-qubit gates.""" p = _check_probability("p", p) return NoiseModel([{"kind": "phase_flip", "p": p}], label=f"phase_flip(p={p:g})")
[docs] def thermal_relaxation(t1: float, t2: float, time: float) -> NoiseModel: """Thermal-relaxation (T1/T2) error applied to single-qubit gates. Parameters ---------- t1 Relaxation time constant (same time unit as *time*). t2 Dephasing time constant. Must satisfy ``t2 <= 2 * t1``. time Gate duration over which relaxation accumulates. """ t1 = _check_positive("t1", t1) t2 = _check_positive("t2", t2) time = _check_positive("time", time) if t2 > 2.0 * t1: raise ValueError(f"thermal_relaxation requires t2 <= 2*t1, got t2={t2}, t1={t1}") return NoiseModel( [{"kind": "thermal_relaxation", "t1": t1, "t2": t2, "time": time}], label=f"thermal_relaxation(t1={t1:g}, t2={t2:g}, time={time:g})", )
[docs] def readout_error(p01: float, p10: float | None = None) -> NoiseModel: """Measurement (readout) error. Parameters ---------- p01 Probability of reading ``1`` when the true state is ``0``. p10 Probability of reading ``0`` when the true state is ``1``. Defaults to *p01* (a symmetric readout error). """ p01 = _check_probability("p01", p01) p10 = p01 if p10 is None else _check_probability("p10", p10) return NoiseModel( [{"kind": "readout", "p01": p01, "p10": p10}], label=f"readout_error(p01={p01:g}, p10={p10:g})", )
# --------------------------------------------------------------------------- # # Named presets (used by the --qtest-noise CLI flag) # # --------------------------------------------------------------------------- # _PRESETS: dict[str, Callable[[], NoiseModel]] = { "depolarizing": lambda: depolarizing(0.01), "bit_flip": lambda: bit_flip(0.01), "phase_flip": lambda: phase_flip(0.01), "readout": lambda: readout_error(0.02), }
[docs] def available_presets() -> list[str]: """Return the sorted names of the built-in noise presets.""" return sorted(_PRESETS)
[docs] def from_preset(name: str) -> NoiseModel: """Return the :class:`NoiseModel` for a built-in preset *name*. Raises ------ ValueError If *name* is not a known preset. """ if name not in _PRESETS: raise ValueError(f"Unknown noise preset {name!r}. Available presets: {available_presets()}") return _PRESETS[name]()