Source code for qdk_chemistry.data.noise_models

"""QDK/Chemistry noise model module for simulating noise in quantum circuits."""

# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import json
from pathlib import Path
from typing import Any, ClassVar

import h5py
from ruamel.yaml import YAML

try:
    from qdk.simulation import NoiseConfig
except ImportError:
    from qsharp._simulation import NoiseConfig

from qdk_chemistry.data._hashing import _hash_float, _hash_str, _hash_uint
from qdk_chemistry.data.base import DataClass
from qdk_chemistry.utils import Logger
from qdk_chemistry.utils.enum import CaseInsensitiveStrEnum

__all__: list[str] = ["SupportedErrorTypes", "SupportedGate"]


[docs] class SupportedGate(CaseInsensitiveStrEnum): """An enumeration of quantum gate types with case-insensitive string lookup. Gate types gathered from QDK QIR instructions. """ CCX = "ccx" CX = "cx" CY = "cy" CZ = "cz" H = "h" ID = "id" MEASURE = "measure" RESET = "reset" RX = "rx" RXX = "rxx" RY = "ry" RYY = "ryy" RZ = "rz" RZZ = "rzz" S = "s" SDG = "sdg" SWAP = "swap" SX = "sx" SXDG = "sxdg" T = "t" TDG = "tdg" X = "x" Y = "y" Z = "z"
[docs] @classmethod def from_string(cls, gate_str: str) -> "SupportedGate": """Get a Gate enum value from its string representation. Args: gate_str: String representation of the gate (case-insensitive). Returns: SupportedGate: The corresponding SupportedGate enum value. Raises: ValueError: If no matching gate is found. """ try: # Leverage internal _missing_ method for case-insensitive lookup return cls(gate_str) except ValueError: # If the gate_str does not match any enum value, raise an error raise ValueError(f"Unknown gate type: {gate_str}") from None
ONE_QUBIT_SUPPORTED_GATES = frozenset( [ SupportedGate.H, SupportedGate.ID, SupportedGate.RX, SupportedGate.RY, SupportedGate.RZ, SupportedGate.S, SupportedGate.SDG, SupportedGate.SX, SupportedGate.SXDG, SupportedGate.T, SupportedGate.TDG, SupportedGate.X, SupportedGate.Y, SupportedGate.Z, ] ) TWO_QUBIT_SUPPORTED_GATES = frozenset( [ SupportedGate.CX, SupportedGate.CY, SupportedGate.CZ, SupportedGate.SWAP, SupportedGate.RXX, SupportedGate.RYY, SupportedGate.RZZ, ] ) THREE_QUBIT_SUPPORTED_GATES = frozenset([SupportedGate.CCX])
[docs] class SupportedErrorTypes(CaseInsensitiveStrEnum): """Supported error types for quantum gates with case-insensitive string lookup.""" DEPOLARIZING_ERROR = "depolarizing_error" QUBIT_LOSS = "qubit_loss"
class QuantumErrorProfile(DataClass): """A class representing a quantum error profile containing information about quantum gates and error properties. This class provides functionalities to define, load, and save quantum error profiles. """ # Class attribute for filename validation _data_type_name = "quantum_error_profile" # Serialization version for this class _serialization_version = "0.1.0" basis_gates_exclusion: ClassVar[set[str]] = {"reset", "barrier", "measure"} """Gates to exclude from basis gates in noise model.""" def __init__( self, name: str | None = None, description: str | None = None, errors: dict[SupportedGate, dict[SupportedErrorTypes, float]] | None = None, ) -> None: """Initialize a QuantumErrorProfile. Args: name: Name of the quantum error profile. description: Description of what the error profile represents. errors: Dictionary mapping gate names to their error properties. """ Logger.trace_entering() self.name: str = "default" if name is None else name self.description: str = "No description provided" if description is None else description self.errors: dict[SupportedGate, dict[SupportedErrorTypes, float]] = {} self.one_qubit_gates: set[SupportedGate] = set() self.two_qubit_gates: set[SupportedGate] = set() self.three_qubit_gates: set[SupportedGate] = set() if errors is not None: for gate_key, error_rates in errors.items(): gate = gate_key if isinstance(gate_key, SupportedGate) else SupportedGate(gate_key) if gate in ONE_QUBIT_SUPPORTED_GATES: self.one_qubit_gates.add(gate) elif gate in TWO_QUBIT_SUPPORTED_GATES: self.two_qubit_gates.add(gate) elif gate in THREE_QUBIT_SUPPORTED_GATES: self.three_qubit_gates.add(gate) validated_rates: dict[SupportedErrorTypes, float] = {} for error_type_key, rate in error_rates.items(): error_type = ( error_type_key if isinstance(error_type_key, SupportedErrorTypes) else SupportedErrorTypes(error_type_key) ) if not isinstance(rate, int | float): raise TypeError(f"Expected rate to be a float, got {type(rate).__name__}") validated_rates[error_type] = float(rate) self.errors[gate] = validated_rates # Make instance immutable after construction (handled by base class) super().__init__() def _hash_update(self, h) -> None: """Feed identifying data into the hasher.""" _hash_str(h, "quantum_error_profile") _hash_str(h, self.name) _hash_str(h, self.description) # Hash errors dict in sorted order _hash_uint(h, len(self.errors)) for gate_key in sorted(self.errors.keys(), key=str): _hash_str(h, str(gate_key)) error_dict = self.errors[gate_key] _hash_uint(h, len(error_dict)) for error_type in sorted(error_dict.keys(), key=str): _hash_str(h, str(error_type)) _hash_float(h, error_dict[error_type]) # These sets are derived from `errors`, but include them deterministically for label, gates in ( ("one_qubit_gates", self.one_qubit_gates), ("two_qubit_gates", self.two_qubit_gates), ("three_qubit_gates", self.three_qubit_gates), ): _hash_str(h, label) _hash_uint(h, len(gates)) for g in sorted(gates, key=str): _hash_str(h, str(g)) def __eq__(self, other: object) -> bool: """Check equality between two QuantumErrorProfile instances. Args: other: Object to compare with. Returns: bool: True if equal, False otherwise. """ if not isinstance(other, QuantumErrorProfile): return False return self.name == other.name and self.description == other.description and self.errors == other.errors def __hash__(self) -> int: """Make QuantumErrorProfile hashable. Returns: int: Hash value. """ # Convert mutable nested dict to immutable tuple for hashing errors_tuple = tuple( sorted((str(k), tuple(sorted((str(et), r) for et, r in v.items()))) for k, v in self.errors.items()) ) return hash((self.name, self.description, errors_tuple)) @property def basis_gates(self) -> list[str]: """Get basis gates from profile. Returns: list[str]: List of basis gates in noise model. """ return [ str(gate) for gate in self.one_qubit_gates | self.two_qubit_gates | self.three_qubit_gates if gate not in self.basis_gates_exclusion ] def to_yaml_file(self, yaml_file: str | Path) -> None: """Save quantum error profile to YAML file. Args: yaml_file: Path to save YAML file. """ yaml = YAML() yaml.default_flow_style = False yaml.indent(mapping=2, sequence=4, offset=2) # Convert to serializable dict data = self.to_json() with Path(yaml_file).open("w", encoding="utf-8") as f: yaml.dump(data, f) @classmethod def from_yaml_file(cls, yaml_file: str | Path) -> "QuantumErrorProfile": """Load quantum error profile from YAML file. Args: yaml_file: Path to YAML file. Returns: QuantumErrorProfile: Loaded profile. """ yaml = YAML(typ="safe") # type: ignore if not Path(yaml_file).exists(): raise FileNotFoundError(f"File {yaml_file} not found") with Path(yaml_file).open("r", encoding="utf-8") as f: data = yaml.load(f) if not isinstance(data, dict): raise ValueError(f"YAML file {yaml_file} is empty or invalid.") invalid_keys = set(data.keys()) - {"version", "name", "description", "errors"} if invalid_keys: raise ValueError( f"Invalid keys in YAML file: {invalid_keys}.\n" "Only 'version', 'name', 'description', and 'errors' are allowed." ) return cls.from_json(data) # DataClass interface implementation def get_summary(self) -> str: """Get a human-readable summary of the QuantumErrorProfile. Returns: str: Summary string describing the quantum error profile. """ data = self.to_json() lines = [ "Quantum Error Profile", f" name: {data['name']}", f" description: {data['description']}", " errors:", ] for gate_str, error_rates in data["errors"].items(): lines.append(f" gate: {gate_str}") for error_type, rate in error_rates.items(): lines.append(f" {error_type}: {rate}") return "\n".join(lines) def to_json(self) -> dict[str, Any]: """Convert the QuantumErrorProfile to a dictionary for JSON serialization. Returns: dict[str, Any]: Dictionary representation of the quantum error profile. """ data: dict[str, Any] = { "name": self.name, "description": self.description, "errors": {}, } # Convert enum keys to strings in the errors dictionary for gate, error_rates in self.errors.items(): gate_str = str(gate) data["errors"][gate_str] = {str(error_type): rate for error_type, rate in error_rates.items()} return self._add_json_version(data) def to_hdf5(self, group: h5py.Group) -> None: """Save the QuantumErrorProfile to an HDF5 group. Args: group: HDF5 group or file to write the quantum error profile to. """ data = self.to_json() group.attrs["version"] = data["version"] group.attrs["name"] = data["name"] group.attrs["description"] = data["description"] # Serialize errors dict as JSON string since HDF5 does not support nested dicts group.attrs["errors"] = json.dumps(data["errors"]) @classmethod def from_json(cls, json_data: dict[str, Any]) -> "QuantumErrorProfile": """Create a QuantumErrorProfile from a JSON dictionary. Args: json_data: Dictionary containing the serialized data. Returns: QuantumErrorProfile: New instance of the QuantumErrorProfile. Raises: RuntimeError: If version field is missing or incompatible. """ cls._validate_json_version(cls._serialization_version, json_data) name = json_data.get("name") description = json_data.get("description") errors: dict[SupportedGate, dict[SupportedErrorTypes, float]] = {} json_errors = json_data.get("errors") if json_errors is not None: for gate_str, error_rates in json_errors.items(): errors[SupportedGate(gate_str)] = {SupportedErrorTypes(et): rate for et, rate in error_rates.items()} return cls(name=name, description=description, errors=errors) @classmethod def from_hdf5(cls, group: h5py.Group) -> "QuantumErrorProfile": """Load a QuantumErrorProfile from an HDF5 group. Args: group: HDF5 group or file to read data from. Returns: QuantumErrorProfile: New instance of the QuantumErrorProfile. Raises: RuntimeError: If version attribute is missing or incompatible. """ data = { "version": group.attrs["version"], "name": group.attrs["name"], "description": group.attrs["description"], "errors": json.loads(group.attrs["errors"]), # Deserialize errors from JSON string } return cls.from_json(data) def to_qdk_noise_config(self) -> NoiseConfig: """Convert the QuantumErrorProfile to a QDK-compatible noise configuration dictionary. Returns: QDK-compatible noise configuration object. """ noise = NoiseConfig() for gate, error_rates in self.errors.items(): gate_name = str(gate) gate_name_qdk = gate_name.lower() if gate_name_qdk == "sdg": gate_name_qdk = "s_adj" elif gate_name_qdk == "tdg": gate_name_qdk = "t_adj" elif gate_name_qdk == "sxdg": gate_name_qdk = "sx_adj" elif gate_name_qdk == "measure": gate_name_qdk = "mresetz" try: gate_config = getattr(noise, gate_name_qdk) except AttributeError: raise ValueError(f"Gate {gate_name} is not supported in QDK noise config.") from None for error_type, rate in error_rates.items(): if error_type == SupportedErrorTypes.DEPOLARIZING_ERROR: gate_config.set_depolarizing(rate) elif error_type == SupportedErrorTypes.QUBIT_LOSS: gate_config.loss = rate else: raise ValueError(f"Error type {error_type} is not currently supported.") return noise