Source code for quchip.devices.base

"""Base class for finite local quantum devices.

Devices own local Hamiltonians on an authored :class:`LocalSpace`. Couplings
and drives contribute separate terms. :meth:`unresolved_hamiltonian` returns
the authored operator, and :meth:`hamiltonian` applies the local basis and
frame policies. Each model must state its approximations and physical
references.

Drive channels use the device's physical lowering, raising, and number
operators. ``sigma_x``, ``sigma_y``, and ``sigma_z`` act on the two lowest
isolated energy states of the current local Hamiltonian.

Numerical physics parameters can be JAX tracers. Structural dimensions and settings
stay fixed during tracing. Validation must not concretize traced values. Use
:func:`quchip.utils.jax_utils.maybe_concrete_scalar` for concrete checks.
:class:`~quchip.utils.state_versioning.StateVersioned` tracks attribute writes, and
:class:`~quchip.utils.registry.Registrable` dispatches deserialization.

Frequencies and energies ``E/h`` are in GHz, times in ns, and temperatures in
mK. Engine assembly converts Hamiltonians to angular frequency.
"""

from __future__ import annotations

from types import MemberDescriptorType
from operator import index
import copy
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Mapping, Self, TypeVar

import jax.numpy as jnp

from quchip.backend import get_default_backend
from quchip.backend.protocol import Operator, State
from quchip.declarative.dissipation import CollapseChannel
from quchip.declarative.parameters import UNBOUND, parameter
from quchip.utils.deprecation import warn_renamed
from quchip.utils.jax_utils import maybe_concrete_scalar
from quchip.utils.labeling import auto_label
from quchip.utils.registry import Registrable
from quchip.utils.state_versioning import StateVersioned

if TYPE_CHECKING:
    from quchip.control.drive import BaseDrive
    from quchip.devices.spaces import LocalSpace, TruncationBoundary
    from quchip.engine.basis import BasisRecord
    from quchip.engine.ir import EngineResult, FrameSpec


# Common device noise fields. Declarative model constructors expose these as
# keyword-only arguments; plain BaseDevice subclasses use the same names in
# their hand-written constructors.
_NOISE_FIELDS: tuple[str, ...] = (
    "T1",
    "T2",
    "thermal_occupation",
)


def _validate_level_pair(lower: Any, upper: Any, dimension: int) -> None:
    """Validate an ordered pair of energy-level indices."""
    for name, value in (("lower", lower), ("upper", upper)):
        if isinstance(value, bool) or not isinstance(value, int):
            raise TypeError(f"{name} must be an integer level index, got {type(value).__name__}.")
        if value < 0:
            raise ValueError(f"{name} must be >= 0, got {value}.")
        if value >= dimension:
            raise ValueError(
                f"{name} level {value} exceeds device dimension {dimension}."
            )
    if lower >= upper:
        raise ValueError(
            f"Transition levels must satisfy lower < upper, got {lower} and {upper}."
        )


def _energy_basis_dissipation(device: Any, p: Any) -> tuple[CollapseChannel, ...]:
    """Build relaxation and dephasing from one retained energy basis."""
    from quchip.engine.basis import resolve_device_basis

    ladder = device.collapse_model == "ladder"
    relaxation = device.T1 is not None or (ladder and device.thermal_occupation is not None)
    dephasing = BaseDevice._dephasing_rate(device.T1, device.T2) is not None
    if not relaxation and not dephasing:
        return ()
    record = resolve_device_basis(
        device, basis="eigen", levels=device.projection_levels or device.local_space().dimension,
    )
    vectors = record.energy_vectors
    transitions: list[tuple[Any, Any]] = []
    if relaxation and ladder:
        lower = jnp.diag(jnp.sqrt(jnp.arange(1, record.resolved_dim)), 1).astype(jnp.complex128)
        transitions.append((vectors @ lower @ vectors.conj().T, 1.0 / p.T1 if device.T1 is not None else 1.0))
    elif relaxation:
        physical = (
            device.phase_coupling_operator()
            if device.coupling_channel == "flux"
            else device.charge_coupling_operator()
        )
        matrix_elements = vectors.conj().T @ physical @ vectors
        normalization = jnp.abs(matrix_elements[0, 1]) ** 2
        norm_concrete = maybe_concrete_scalar(normalization)
        if norm_concrete is not None and norm_concrete < 1e-24:
            raise ValueError("The selected coupling_channel has a dark 0-to-1 transition.")
        for upper in range(1, record.resolved_dim):
            for lower_index in range(upper):
                rate_ratio = jnp.abs(matrix_elements[lower_index, upper]) ** 2 / normalization
                ratio_concrete = maybe_concrete_scalar(rate_ratio)
                if ratio_concrete is not None and ratio_concrete < device.collapse_rate_threshold:
                    continue
                down = jnp.outer(vectors[:, lower_index], vectors[:, upper].conj())
                transitions.append((down, rate_ratio / p.T1))
    occupation = None
    if device.thermal_occupation is not None:
        # A concrete zero stays exact, so the absorption rate is an exact zero.
        occupation = 0.0 if maybe_concrete_scalar(device.thermal_occupation) == 0.0 else p.thermal_occupation
    channels = [
        channel
        for down, rate in transitions
        for channel in BaseDevice._emission_channels(
            rate, occupation, down, down.conj().T,
            emission_name="matrix_element_emission", absorption_name="matrix_element_absorption",
        )
    ]
    if dephasing:
        rate = 1.0 / p.T2
        if device.T1 is not None:
            rate = rate - 1.0 / (2.0 * p.T1)
        channels.append(CollapseChannel(record.authored_level_operator(), 2.0 * rate, "pure_dephasing"))
    return tuple(channels)


#: Self-type for fluent helpers (e.g. ``_restore_reference_freq``) so a
#: ``from_dict`` returning ``cls(...)._restore_reference_freq(d)`` keeps the
#: concrete subclass type.
_DeviceT = TypeVar("_DeviceT", bound="BaseDevice")


[docs] class BaseDevice(StateVersioned, Registrable, ABC, registry_root=True): """Abstract truncated-Hilbert-space quantum device. Concrete subclasses must: 1. Set ``_type_prefix`` (used for auto-labeling). 2. Expose a ``freq`` attribute holding the bare ``0 -> 1`` transition frequency in GHz, as any JAX-traceable scalar. 3. Implement :meth:`unresolved_hamiltonian` on the authored local space. Noise parameters (all optional, ``None`` means the channel is absent): * ``T1``: relaxation time (ns), giving an emission channel at rate ``1/T1``. * ``T2``: total 0-1 coherence time (ns), with ``T2 <= 2*T1``. It adds pure dephasing at ``gamma_phi = 1/T2 - 1/(2*T1)``. * ``thermal_occupation``: unitless bath occupation ``n̄``. It adds thermal absorption and increases emission. The noise parameters are ordinary attributes, settable at construction or any time later. A newly built calculation uses the current values, and existing calculations keep their captured values. Writes get the same validation as construction. Setting a parameter to ``None`` removes its channel from later calculations. Mutation tracking starts automatically when construction finishes (see :class:`~quchip.utils.state_versioning.StateVersioned`). Subclasses do not call ``_finish_init``. Optional overrides: * :meth:`dissipation`: append channels in addition to ``T1``/``T2``. * :meth:`to_dict` / :meth:`from_dict`: for extra parameters. * :attr:`computational`: ``True`` if the device represents a computational qubit (default ``False``). See the module docstring for the full contract. """ _type_prefix: ClassVar[str] = "device" T1: Any = parameter(default=None, positive=True, unit="ns", noise=True, kw_only=True) T2: Any = parameter(default=None, positive=True, unit="ns", noise=True, kw_only=True) thermal_occupation: Any = parameter( default=None, nonnegative=True, noise=True, kw_only=True, ) #: Parameters eligible for inverse design. DeviceModel derives these #: from declared fields unless the class or an ancestor supplies a tuple. #: Explicit tuples remain authoritative when inherited; an empty tuple #: excludes the device from inverse design. Plain BaseDevice subclasses #: default to an empty tuple without derivation. tunable_param_names: ClassVar[tuple[str, ...]] = () #: ``(dressed_observable, declared_field)`` pairs used when this device #: appears in the desired-chip form of ``fit_a_dress``. Empty means that #: the model makes no automatic dressed-target claim; circuit-level models #: can remain fixed until the user supplies explicit constraints. dressed_fit_target_fields: ClassVar[tuple[tuple[str, str], ...]] = () #: Bare parameters normally varied to reproduce #: :attr:`dressed_fit_target_fields`. This remains separate from #: :attr:`tunable_param_names`: a model may expose parameters for sweeps #: without claiming that inverse design can identify all of them from its #: default dressed observables. dressed_fit_param_names: ClassVar[tuple[str, ...]] = () # Labels are fixed identity metadata. Other public assignments are tracked. _untracked_names = frozenset({"label"}) # Per-device readout/rotating-frame reference override; ``None`` inherits # the calculation's dressed reference. Class-level default remains safe on the # JAX-pytree ``_unflatten`` path (which bypasses ``__init__``). _reference_freq_override: Any = None basis: Literal["native", "eigen"] | None = None projection_levels: int | None = None requires_projection_levels: ClassVar[bool] = False def __init__( self, levels: int, label: str | None = None, *, T1: float | None = None, T2: float | None = None, thermal_occupation: float | None = None, thermal_population: Any = UNBOUND, ) -> None: if thermal_population is not UNBOUND: if thermal_occupation is not None: raise TypeError("Pass thermal_occupation only, not both thermal occupation names.") warn_renamed("thermal_population", "thermal_occupation") thermal_occupation = thermal_population if index(levels) < 2: raise ValueError(f"levels must be >= 2, got {levels}") self.levels = levels _validate_noise_params(T1, T2, thermal_occupation) self.T1 = T1 self.T2 = T2 self.thermal_occupation = thermal_occupation self.label = label if label is not None else auto_label(type(self)._type_prefix) self._connected_drives: list[BaseDrive] = [] self._reference_freq_override: Any = None def __setattr__(self, name: str, value: Any) -> None: """Give post-construction writes the same validation as the constructor. Construction validates jointly. After construction, every public write is validated *before* the attribute lands. A rejected value therefore never sticks. Checks apply only to concrete scalars, so traced writes go through unchecked. """ # Joint construction validation runs while mutation tracking is still # off (`__init__` / the declarative resolver). When tracking is live, # every public write runs `_validate_param_write` before the attribute # lands. The JAX pytree `_unflatten` path uses `object.__setattr__` and # bypasses `__setattr__`. if name == "thermal_population": warn_renamed(name, "thermal_occupation") name = "thermal_occupation" if getattr(self, "_tracking_enabled", False): if name == "label": raise AttributeError("Device label is fixed at construction; create a replacement device.") if not name.startswith("_"): self._validate_param_write(name, value) super().__setattr__(name, value) @property def thermal_population(self) -> Any: """Deprecated alias for the bath's mean thermal occupation.""" warn_renamed("thermal_population", "thermal_occupation") return self.thermal_occupation @thermal_population.setter def thermal_population(self, value: Any) -> None: warn_renamed("thermal_population", "thermal_occupation") self.thermal_occupation = value @staticmethod def _normalize_parameter_names(values: Mapping[str, Any]) -> dict[str, Any]: """Read legacy thermal occupation inputs without duplicating parameter state.""" values = dict(values) if "thermal_population" in values: if "thermal_occupation" in values: raise TypeError("Pass thermal_occupation only, not both thermal occupation names.") warn_renamed("thermal_population", "thermal_occupation") values["thermal_occupation"] = values.pop("thermal_population") return values def _validate_param_write(self, name: str, value: Any) -> None: """Constructor-grade validation for one post-construction write. The base class checks the noise fields jointly — the same :func:`_validate_noise_params` the constructor runs, with *value* substituted for the field being written. Without this, e.g. ``q.T2 = 3 * q.T1`` after construction would not raise but silently drop the pure-dephasing channel (its implied rate goes negative). Subclasses extend (``DeviceModel`` adds declared-parameter sign checks) and must call ``super()``. """ if name == "levels" and index(value) < 2: raise ValueError(f"levels must be >= 2, got {value}") if name in ("basis", "projection_levels"): self._validate_basis_request( basis=value if name == "basis" else self.basis, levels=value if name == "projection_levels" else self.projection_levels, native_dimension=self.local_space().dimension, ) if name in _NOISE_FIELDS: candidate = {field: getattr(self, field, None) for field in _NOISE_FIELDS} candidate[name] = value _validate_noise_params(**candidate) if name == "collapse_model" and value not in ("fermi_golden", "ladder"): raise ValueError( f"collapse_model must be 'fermi_golden' or 'ladder', got {value!r}" ) if name in ("T1", "collapse_model", "coupling_channel") and hasattr( self, "collapse_model" ): model = value if name == "collapse_model" else self.collapse_model t1 = value if name == "T1" else self.T1 channel = value if name == "coupling_channel" else getattr( self, "coupling_channel", None ) if model == "fermi_golden" and t1 is not None and channel is None: raise ValueError( "coupling_channel is required when T1 uses matrix-element relaxation." ) # -- Bare-parameter introspection (inverse design / autodiff) ------------
[docs] def tunable_params(self) -> dict[str, Any]: """Return ``{name: current_value}`` for every bare parameter the device exposes for fitting / sweeping. By default, it walks :attr:`tunable_param_names` and reads each attribute. Subclasses with derived bare parameters, e.g. circuit-level devices that calculate ``freq`` from ``E_C``/``E_J``/``E_L``, should override the class attribute, not this method. Override this method only when the *list* itself is not static, e.g. for flux-tunable devices that gain ``phi_ext`` only at some operating points. """ return {name: getattr(self, name) for name in self.tunable_param_names}
[docs] def default_dressed_targets(self) -> dict[str, Any]: """Return declared numbers interpreted as dressed-fit targets. This hook reads component fields and never diagonalizes a chip. A device class opts in by declaring :attr:`dressed_fit_target_fields`. """ return { observable: getattr(self, field) for observable, field in self.dressed_fit_target_fields }
[docs] def default_fit_parameters(self) -> tuple[str, ...]: """Return the conservative bare-parameter selection for default targets.""" return self.dressed_fit_param_names
[docs] def set_tunable_param(self, name: str, value: Any) -> None: """Update a bare parameter named in :meth:`tunable_params`. Parameters ---------- name : str Tunable field name. value : Any Replacement numerical value. Notes ----- The default implementation uses :func:`setattr`, so any direct attribute (``freq``, ``anharmonicity``, ``E_C``, …) works. Subclasses with derived properties that must back-propagate to private state should override this method. """ if name not in self.tunable_param_names: raise ValueError( f"{type(self).__name__} does not expose {name!r} as a tunable " f"parameter. Allowed: {list(self.tunable_param_names)}" ) setattr(self, name, value)
[docs] def tunable_param_bounds(self, name: str, value: float) -> tuple[float, float]: """Return ``(lower, upper)`` bounds for a tunable parameter at a seed value. Parameters ---------- name : str Tunable parameter name. value : float Concrete seed used to construct bounds. Notes ----- The inverse-design optimizer uses these bounds to keep searches physical. The default uses well-named conventions that cover the common circuit-QED parameters: * ``freq``, ``E_C``, ``E_J``, ``E_L``: positive, ``[0.5·s, 1.5·s]`` around a positive seed (``s``). * ``anharmonicity``: sign-preserving. Negative seeds bound in ``(2·s, -ε)``, and positive seeds bound in ``(ε, 2·s)``. * ``phi_ext``: in ``[-0.5, 0.5]`` (one full flux period symmetric around the integer-flux point). Subclasses override this method for parameters with other physical constraints. Raises :class:`ValueError` for unknown names rather than silently optimizing over an unbounded axis, and for a *value* that is not a concrete real scalar. Bounds for a JAX tracer are undefined because the optimizer needs a concrete numeric seed. """ seed = maybe_concrete_scalar(value) if seed is None: raise ValueError( f"tunable_param_bounds({name!r}, {value!r}) requires a concrete real scalar " "seed; optimizer bounds cannot be computed from a JAX tracer." ) if name in {"freq", "E_C", "E_J", "E_L"}: if seed <= 0: raise ValueError(f"{name} seed must be positive, got {seed}") return (max(1e-6, 0.5 * seed), 1.5 * seed) if name == "anharmonicity": if seed < 0: return (2.0 * seed, -1e-6) return (1e-6, 2.0 * seed if seed > 0 else 1.0) if name == "phi_ext": return (-0.5, 0.5) raise ValueError( f"{type(self).__name__} has no bounds rule for tunable parameter {name!r}; " "override tunable_param_bounds()." )
@property def connected_drives(self) -> list["BaseDrive"]: """Drives wired to this device, as a fresh list (mutation-safe copy).""" return list(self._connected_drives)
[docs] def copy(self) -> Self: """Structural copy detached from drive wiring (used by sweep cloning).""" from quchip.declarative.parameters import copy_authored_fields cloned = copy.copy(self) copy_authored_fields(self, cloned) object.__setattr__(cloned, "_connected_drives", []) return cloned
[docs] def parameter_values(self) -> dict[str, Any]: """Return declared bindable values, including inactive optional fields.""" from quchip.declarative.parameters import parameter_fields values = dict(self.tunable_params()) values.update((name, getattr(self, name)) for name in parameter_fields(type(self))) return values
[docs] def set_parameter_value(self, name: str, value: Any) -> None: """Apply one validated local parameter value on an isolated device copy. Parameters ---------- name : str Declared or tunable parameter name. value : Any Replacement value. """ if name == "thermal_population": warn_renamed(name, "thermal_occupation") name = "thermal_occupation" tunable = self.tunable_params() if name in tunable: self.set_tunable_param(name, value) return from quchip.declarative.parameters import parameter_fields if name in parameter_fields(type(self)): setattr(self, name, value) return raise KeyError(name)
[docs] def set_parameter_values(self, values: Mapping[str, Any]) -> None: """Validate a complete candidate before applying a local parameter group. Parameters ---------- values : mapping[str, Any] Parameter names and replacement values, applied atomically. """ values = self._normalize_parameter_names(values) if not values: return from quchip.utils.jax_utils import contains_tracer if contains_tracer(tuple(values.values())): changed = [ name for name, value in values.items() if (getattr(self, name, None) is None) != (value is None) ] if changed: raise ValueError( f"Activate or deactivate optional parameters {changed} with concrete values before tracing." ) self._commit_parameter_candidate(self._parameter_candidate(values))
def _parameter_candidate(self, values: Mapping[str, Any]) -> "BaseDevice": """Prepare validated state without changing this device or its ownership.""" from quchip.declarative.parameters import copy_authored_fields candidate = copy.copy(self) copy_authored_fields(self, candidate) object.__setattr__(candidate, "_tracking_enabled", False) for name, value in values.items(): candidate.set_parameter_value(name, value) for name in values: candidate._validate_param_write(name, getattr(candidate, name)) candidate.validate() object.__setattr__(candidate, "_tracking_enabled", self._tracking_enabled) object.__setattr__(candidate, "_state_version", self.state_version + 1) return candidate def _commit_parameter_candidate(self, candidate: "BaseDevice") -> None: """Adopt validated attribute storage, including slots declared by extensions.""" for cls in type(self).__mro__: for descriptor in vars(cls).values(): if isinstance(descriptor, MemberDescriptorType) and hasattr(candidate, descriptor.__name__): descriptor.__set__(self, descriptor.__get__(candidate)) object.__setattr__(self, "__dict__", candidate.__dict__)
[docs] def validate(self) -> None: """Validate cross-field constraints at construction and grouped rebinding. Subclasses implement their joint constraints here. Gate numerical checks on concrete scalars so traced parameters remain supported. """
# -- Drive lookup ------------------------------------------------------- def __getitem__(self, key: str) -> "BaseDrive": for drv in self._connected_drives: if drv.label == key: return drv available = [d.label for d in self._connected_drives] raise KeyError(f"No drive {key!r} on device {self.label!r}. Available drives: {available}") from None def __contains__(self, item: object) -> bool: if isinstance(item, str): return any(d.label == item for d in self._connected_drives) return item in self._connected_drives @property def reference_freq(self) -> Any: """Return the authored readout/frame reference in GHz, or ``None``. ``None`` lets each chip calculation resolve its dressed reference. An explicit value fixes the readout reference independently of pulse carriers and is captured in each calculation's resolved frame. """ return self._reference_freq_override @reference_freq.setter def reference_freq(self, value: Any | None) -> None: """Set the readout/rotating-frame reference (``None`` restores automatic resolution).""" self._reference_freq_override = value # -- Serialization ------------------------------------------------------
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-safe serialization. Subclasses extend it with their own parameters.""" data = super().to_dict() data["levels"] = int(self.levels) data["label"] = self.label for attr in _NOISE_FIELDS: value = getattr(self, attr) if value is not None: data[attr] = float(value) # Persist an explicit reference_freq override (not the resolved # default). Skip a traced override — it has no concrete serializable # value, matching how the rest of to_dict emits concrete scalars only. override = self._reference_freq_override if override is not None: override_value = maybe_concrete_scalar(override) if override_value is not None: data["reference_freq"] = float(override_value) return data
@staticmethod def _noise_kwargs_from_dict(d: dict[str, Any]) -> dict[str, Any]: """Pull noise kwargs out of a serialization dict (helper for subclass from_dict).""" return {field: d.get(field) for field in _NOISE_FIELDS} def _restore_reference_freq(self: _DeviceT, d: dict[str, Any]) -> _DeviceT: """Restore a serialized ``reference_freq`` override (helper for from_dict). Sets the override only when the key is present (an absent key keeps the automatic default). Returns ``self`` (typed as the concrete subclass) so ``from_dict`` can ``return cls(...)._restore_reference_freq(d)``. """ if "reference_freq" in d: self.reference_freq = d["reference_freq"] return self # -- Hamiltonian -------------------------------------------------------
[docs] @abstractmethod def unresolved_hamiltonian(self) -> Operator: """Return the authored local Hamiltonian before engine policies.""" ...
[docs] def hamiltonian(self) -> Any: """Return the local Hamiltonian after basis and frame policies.""" return self.resolve().hamiltonian()
[docs] def resolve(self, *, frame: FrameSpec | None = None) -> EngineResult: """Resolve isolated device physics with its own basis and explicit frame. Parameters ---------- frame : FrameSpec or None, keyword-only Explicit rotating-frame specification; ``None`` uses the lab frame. The default frame is the lab frame. Chip membership does not affect this local calculation; use the chip for coupled-system queries. """ from quchip.chip.chip import Chip return Chip([self.copy()]).resolve(frame=frame)
# -- Declared approximations --------------------------------------------
[docs] def truncation_boundary(self) -> TruncationBoundary | None: """Return the authored-space cutoff used by sampled truncation checks. Intrinsically finite models override this method to return None. Custom spaces with a numerical cutoff return a TruncationBoundary describing its indices. """ return self.local_space().truncation_boundary()
def _truncation_note(self) -> str: """Return the Hilbert-truncation physics note. Default states the Fock-basis truncation. Models with another authored local space override this hook so the remaining physics notes stay shared. """ return f"Hilbert truncation: {self.levels} Fock levels"
[docs] def physics_notes(self) -> list[str]: """Return human-readable declarations of this device's approximations. Each entry names a non-obvious assumption, approximation, or truncation of this device. Examples include "Hilbert truncation: 3 levels", a model regime (Duffing), or a noise-channel selection (charge- vs flux-coupled T1). The baseline entry is a truncation note, because every :class:`BaseDevice` has some form of Hilbert-space truncation. A pure-dephasing note is added when ``T2`` is set, because the number-operator dephasing model has non-obvious assumptions. Subclasses call ``super().physics_notes()`` and append their own model-specific notes. """ # The baseline truncation note comes from `_truncation_note`. Physics # notes need no registry / engine-side dispatch. notes = [self._truncation_note()] if self.T2 is not None: notes.append( "Pure dephasing couples to the level-number operator " "(rate scales as (m-n)^2 across levels); the input T2 equals " "the resulting 0-1 coherence time only when thermal_occupation " "is 0." ) return notes
def _time_terms(self) -> tuple[Any, ...]: """Return normalized time-dependent terms (default: none).""" return () # -- Fock-space operator defaults --------------------------------------
[docs] def local_space(self) -> "LocalSpace": """Return this device's authored local operator space.""" from quchip.devices.spaces import FockSpace return FockSpace(self.levels)
[docs] def resolved_basis( self, chip_basis: Literal["native", "eigen"] = "native", ) -> Literal["native", "eigen"]: """Return the device override or inherited chip basis policy. Parameters ---------- chip_basis : {"native", "eigen"}, default "native" Fallback policy when the device has no explicit basis setting. """ policy = self.basis if self.basis is not None else chip_basis if policy not in ("native", "eigen"): raise ValueError(f"basis must be 'native', 'eigen', or None, got {policy!r}.") return policy
@classmethod def _validate_basis_request( cls, *, basis: Literal["native", "eigen"] | None, levels: int | None, native_dimension: int, ) -> None: """Validate one local-basis policy against its authored dimension.""" if basis not in (None, "native", "eigen"): raise ValueError(f"basis must be 'native', 'eigen', or None, got {basis!r}") if basis == "native" and levels is not None: raise ValueError("levels is not valid when basis='native'.") if basis == "eigen" and cls.requires_projection_levels and levels is None: raise ValueError("levels is required when basis='eigen'.") if levels is not None and not 1 <= index(levels) <= native_dimension: raise ValueError( f"levels must be between 1 and {native_dimension}, got {levels}" )
[docs] def resolved_dimension( self, chip_basis: Literal["native", "eigen"] = "native", ) -> int: """Return the local dimension delivered to the solver. Parameters ---------- chip_basis : {"native", "eigen"}, default "native" Fallback basis policy. """ policy = self.resolved_basis(chip_basis) if policy == "native": return self.local_space().dimension levels = self.projection_levels if levels is None: if self.requires_projection_levels: raise ValueError( f"{type(self).__name__} {self.label!r} requires levels when basis='eigen'." ) levels = self.local_space().dimension if levels < 1 or levels > self.local_space().dimension: raise ValueError( f"levels must be between 1 and {self.local_space().dimension}, got {levels}." ) return levels
[docs] def lowering_operator(self) -> Operator: """Bosonic lowering operator ``a`` on the truncated Fock basis.""" return self.local_space().operator("a", get_default_backend())
[docs] def raising_operator(self) -> Operator: """Bosonic raising operator ``a†`` on the truncated Fock basis.""" return self.local_space().operator("adag", get_default_backend())
[docs] def number_operator(self) -> Operator: """Number operator ``n̂ = a†a`` on the truncated Fock basis.""" return self.local_space().operator("n", get_default_backend())
[docs] def energy_level_operator(self) -> Operator: """Return the energy-level index expressed in the authored local basis.""" from quchip.engine.basis import resolve_device_basis import jax with jax.ensure_compile_time_eval(): return resolve_device_basis(self, basis="native").level_operator()
[docs] def identity(self) -> Operator: """Identity operator on the truncated Fock basis.""" return self.local_space().operator("I", get_default_backend())
[docs] def local_operator(self, name: str) -> Operator: """Map an operator-name string to this device's own local operator. Parameters ---------- name : str Operator vocabulary name such as ``"X"``, ``"n"``, ``"a"``, ``"charge"`` or ``"I"``. Notes ----- Recognized names: ``"X"`` / ``"Y"`` / ``"Z"`` (Pauli projections on the computational ``|0>, |1>`` subspace), ``"n"`` (number), ``"a"`` (lowering), ``"a_dag"`` (raising), ``"I"`` (identity). The device owns this vocabulary. ``"charge"``, ``"phase"`` and ``"flux"`` use the corresponding physical coupling operator when the device declares it. Other names are found in :meth:`local_space`. A subclass can override this method to supply more derived operators. """ physical = { "charge": "charge_coupling_operator", "phase": "phase_coupling_operator", "flux": "flux_coupling_operator", } method = getattr(self, physical.get(name, ""), None) if method is not None: return method() if name == "X": return self.sigma_x if name == "Y": return self.sigma_y if name == "Z": return self.sigma_z if name == "n": return self.number_operator() if name == "a": return self.lowering_operator() if name == "a_dag": return self.raising_operator() if name == "I": return self.identity() try: return self.local_space().operator(name, get_default_backend()) except ValueError as error: raise ValueError(f"Unknown operator {name!r} for device {self.label!r}: {error}") from error
[docs] def basis_state(self, n: int) -> State: """Return Fock basis state ``|n>``. Parameters ---------- n : int Non-negative basis index below ``levels``. """ return get_default_backend().basis(self.levels, n)
[docs] def coherent_state(self, alpha: complex) -> State: """Return coherent state ``|alpha>`` on the truncated Fock basis. Parameters ---------- alpha : complex Dimensionless coherent-state amplitude. """ return get_default_backend().coherent(self.levels, alpha)
[docs] def plot_energy_levels(self, *, ax: Any = None, **kwargs: Any) -> Any: """Plot the device's bare energy-level ladder. Parameters ---------- ax : matplotlib Axes or None, keyword-only Existing axes, or ``None`` to create one. **kwargs : Any Plot styling forwarded to the visualization helper. """ from quchip.viz.device import plot_energy_levels return plot_energy_levels(self, ax=ax, **kwargs)
[docs] def plot_wavefunction(self, n: int, *, ax: Any = None, **kwargs: Any) -> Any: """Plot an eigenstate wavefunction. Parameters ---------- n : int Eigenstate index. ax : matplotlib Axes or None, keyword-only Existing axes, or ``None`` to create one. **kwargs : Any Plot styling forwarded to the visualization helper. """ from quchip.viz.device import plot_wavefunction return plot_wavefunction(self, n, ax=ax, **kwargs)
# -- Pauli operators on the two lowest isolated energy states ----------- def _pauli_operator(self, name: str, *, basis: BasisRecord | None = None) -> Operator: from quchip.declarative.parameters import component_fingerprint from quchip.devices.spaces import FockSpace from quchip.engine.basis import resolve_device_basis from quchip.utils.jax_utils import contains_tracer matrices: dict[str, Any] = {} if basis is None: key = component_fingerprint(self) cached = getattr(self, "_pauli_cache", None) if cached is not None and cached[0] == key: _, basis, matrices = cached else: basis = resolve_device_basis(self, basis="native") # A traced record would keep its trace alive in the cache. if not contains_tracer((basis.energies, basis.energy_vectors)): self._pauli_cache = (key, basis, matrices) assert basis is not None matrix = matrices.get(name) if matrix is None: vectors = basis.energy_vectors[:, :2] matrix = vectors @ FockSpace(2).matrix(name) @ vectors.conj().T # Cache immutable arrays only; each caller owns its lowered operator. if not contains_tracer(matrix): matrices[name] = matrix dimension = self.local_space().dimension return get_default_backend().from_array(matrix, dims=[[dimension], [dimension]]) @property def sigma_x(self) -> Operator: """Return ``|0><1| + |1><0|`` on isolated energy levels, zero elsewhere.""" return self._pauli_operator("sigma_x") @property def sigma_y(self) -> Operator: """Return ``-i|0><1| + i|1><0|`` on isolated energy levels, zero elsewhere.""" return self._pauli_operator("sigma_y") @property def sigma_z(self) -> Operator: """Return ``|0><0| - |1><1|`` on isolated energy levels, zero elsewhere.""" return self._pauli_operator("sigma_z") @property def sigma_plus(self) -> Operator: """Return ``|1><0|`` between the lowest isolated energy levels.""" return self._pauli_operator("sigma_plus") @property def sigma_minus(self) -> Operator: """Return ``|0><1|`` between the lowest isolated energy levels.""" return self._pauli_operator("sigma_minus")
[docs] def projector(self, i: int, j: int) -> Operator: """``|i><j|`` on the authored local basis. Parameters ---------- i, j : int Ket and bra indices in the authored local basis. Use ``projector(i, i)`` for the population projector ``|i><i|`` and ``projector(i, j)`` for ``|i><j|``. No subspace approximation: the operator acts on the full authored local space. """ backend = get_default_backend() ket_i = backend.basis(self.local_space().dimension, i) ket_j = backend.basis(self.local_space().dimension, j) return backend.matmul(ket_i, backend.dag(ket_j))
[docs] def transition(self, lower: int, upper: int) -> Operator: """Hermitian transition between isolated energy states. Parameters ---------- lower, upper : int Isolated energy-level indices with ``lower < upper``. The operator ``|lower><upper| + |upper><lower|`` is returned in the authored local basis. """ from quchip.engine.basis import resolve_device_basis authored_dimension = self.local_space().dimension _validate_level_pair( lower, upper, self.resolved_dimension(self.basis or "native"), ) vectors = resolve_device_basis(self, basis="native").energy_vectors off_diagonal = jnp.outer(vectors[:, lower], jnp.conj(vectors[:, upper])) matrix = off_diagonal + jnp.conj(off_diagonal.T) return get_default_backend().from_array( matrix, dims=[[authored_dimension], [authored_dimension]], )
[docs] def transition_frequency(self, lower: int, upper: int) -> Any: """Return the isolated ``E_upper - E_lower`` transition in GHz. Parameters ---------- lower, upper : int Isolated energy-level indices with ``lower < upper``. """ from quchip.engine.basis import resolve_device_basis _validate_level_pair( lower, upper, self.resolved_dimension(self.basis or "native"), ) energies = resolve_device_basis(self, basis="native").energies return energies[upper] - energies[lower]
# -- Classification ---------------------------------------------------- @property def computational(self) -> bool: """Return True if this device is a computational qubit. Override in subclasses.""" return False # -- Collapse operators ------------------------------------------------
[docs] def dissipation(self, op: Any, p: Any) -> tuple[CollapseChannel, ...]: """Return T1/T2 channels using the device's lowering, raising and number hooks.""" def operator(name: str, hook: str) -> Any: # Keep inherited operators symbolic for parameter discovery and # linear-response lowering; overridden hooks own their physics. if getattr(type(self), hook) is getattr(BaseDevice, hook): return op[name] return getattr(self, hook)() channels: list[CollapseChannel] = [] if self.T1 is not None or self.thermal_occupation is not None: occupation = 0.0 if self.thermal_occupation is None else p.thermal_occupation base_rate = 1.0 / p.T1 if self.T1 is not None else 1.0 channels.append( CollapseChannel(operator("a", "lowering_operator"), base_rate * (occupation + 1.0), "thermal_emission") ) if self.thermal_occupation is not None: # A concrete zero gives an exact zero rate, so gradients avoid sqrt'(0). exact_zero = maybe_concrete_scalar(self.thermal_occupation) == 0.0 channels.append( CollapseChannel(operator("adag", "raising_operator"), 0.0 if exact_zero else base_rate * occupation, "thermal_absorption") ) gamma_phi = self._dephasing_rate(self.T1, self.T2) if gamma_phi is not None: symbolic_gamma = 1.0 / p.T2 if self.T1 is not None: symbolic_gamma = symbolic_gamma - 1.0 / (2.0 * p.T1) channels.append(CollapseChannel(operator("n", "number_operator"), 2.0 * symbolic_gamma, "pure_dephasing")) return tuple(channels)
def _collapse_channels_with_paths( self, basis: "BasisRecord | None" = None, ) -> tuple[tuple[CollapseChannel, tuple[str, ...]], ...]: """Normalize authored local dissipation and infer parameter paths.""" from quchip.declarative.dissipation import normalize_dissipation from quchip.declarative.expr import ParameterNamespace from quchip.declarative.ops import LocalOps from quchip.declarative.parameters import parameter_fields del basis space = self.local_space() op = LocalOps(label=self.label, space=space, device=self) fields = parameter_fields(type(self)) p = ParameterNamespace(self.label, fields) bindings = { f"{self.label}.{name}": value for name in fields if (value := getattr(self, name)) is not None } return normalize_dissipation( self.dissipation(op, p), labels=(self.label,), dims=(space.dimension,), owner=self, scope=self.label, allowed=fields, bindings=bindings, )
[docs] def collapse_operators(self) -> list[Operator]: """Materialize the device's authored Lindblad collapse operators. The built-in channels cover ``T1``, ``T2``, and ``thermal_occupation``. Subclasses append channels in :meth:`dissipation`. References ---------- Breuer & Petruccione, *Theory of Open Quantum Systems* (Oxford, 2002), Ch. 3. For circuit-QED conventions see Krantz et al., *Applied Physics Reviews* **6**, 021318 (2019), §V. """ from quchip.declarative.expr import materialize_expr from quchip.engine.basis import resolve_device_basis backend = get_default_backend() policy = self.resolved_basis() levels = self.resolved_dimension() if policy == "eigen" else None basis = resolve_device_basis(self, basis=policy, levels=levels) dims = [[basis.resolved_dim], [basis.resolved_dim]] operators: list[Operator] = [] for channel, _paths in self._collapse_channels_with_paths(basis): authored = materialize_expr(channel.operator, backend) if basis.kind == "native": native = authored else: projected = basis.transform_operator(backend.to_array(authored)) native = backend.from_array(projected, dims=dims) rate = materialize_expr(channel.rate, backend) operators.append(jnp.sqrt(rate) * native) return operators
[docs] def collapse_channels( self, basis: "BasisRecord | None" = None, ) -> tuple[CollapseChannel, ...]: """Return normalized local collapse channels. Parameters ---------- basis : BasisRecord or None, optional Resolved basis for matrix-element channels. ``None`` resolves the device's current basis policy. """ return tuple(channel for channel, _paths in self._collapse_channels_with_paths(basis))
[docs] @classmethod def noise_parameter_names(cls) -> tuple[str, ...]: """Declared fields that :meth:`Chip.set_noise` can configure.""" from quchip.declarative.parameters import parameter_fields return tuple( name for name, spec in parameter_fields(cls).items() if spec.noise )
[docs] def intrinsic_decay_rate(self) -> Any | None: """Return the total lowering-channel (downward) Lindblad rate in 1/ns, or ``None`` if there is no decay channel. Reports the sum of squared amplitudes of the lowering-operator collapse channel(s) that :meth:`collapse_operators` builds from the common thermal-emission construction. It reproduces that construction exactly: * ``T1`` set (``thermal_occupation`` set or not): ``(n̄+1)/T1``, the rate of the ``sqrt(gamma*(n̄+1))·a`` channel with ``gamma = 1/T1``. ``n̄`` defaults to ``0`` when ``thermal_occupation`` is unset, so the rate reduces to plain ``1/T1``. * ``T1`` unset, ``thermal_occupation`` set: ``n̄+1``, the same channel with ``gamma = 1`` (the unitless-bath-occupation branch). * Neither set: ``None``, because there is no lowering channel. Subclasses whose :meth:`collapse_operators` combines several lowering-operator channels override this method to report the summed rate. An example is the Q-derived photon loss of :class:`~quchip.devices.resonator.Resonator` together with ``T1``. The method reports only the *downward* rate, not the ``sqrt(gamma*n̄)·a†`` upward (thermal-absorption) channel. A caller that needs to know whether that channel is present reads ``thermal_occupation`` directly. """ # A caller that reads a single scalar decay rate, e.g. the Purcell fold # of `quchip.chip.transformations.eliminate_device`, then need not # special-case each device's channel structure. # # A channel's existence and the applicable formula are a static decision # (is `T1`/`thermal_occupation` set?). It is never a traced-zero # comparison on the resulting rate, which would concretize a traced # value and break differentiability. n_bar = self.thermal_occupation if self.T1 is not None: n_bar_eff = 0.0 if n_bar is None else n_bar return (n_bar_eff + 1.0) / self.T1 if n_bar is not None: return n_bar + 1.0 return None
# -- Shared Lindblad rate algebra ---------------------------------------- @staticmethod def _emission_channels( rate: Any, n_bar: Any | None, lower_op: Any, raise_op: Any, *, emission_name: str, absorption_name: str, ) -> list[CollapseChannel]: """Emission and absorption channels for one transition. Returns ``(lower_op, rate * (n_bar + 1))`` (relaxation / stimulated emission) and, when a bath occupation is declared, additionally ``(raise_op, rate * n_bar)`` (thermal absorption). ``n_bar is None`` yields the down channel only. An explicit zero keeps absorption support fixed during parameter rebinding. A concrete zero gives an exact zero absorption rate, so gradients over other parameters stay finite. The math is pure :mod:`jax.numpy`, so the result type follows the supplied operators. """ n_bar_eff = 0.0 if n_bar is None else n_bar terms = [ CollapseChannel( lower_op, rate * (n_bar_eff + 1.0), emission_name, ) ] if n_bar is not None: terms.append( CollapseChannel( raise_op, 0.0 if maybe_concrete_scalar(n_bar) == 0.0 else rate * n_bar_eff, absorption_name, ) ) return terms @staticmethod def _dephasing_rate(T1: Any | None, T2: Any | None) -> Any | None: """Clamped pure-dephasing rate ``gamma_phi``, or ``None`` when absent. ``gamma_phi = 1/T2 - 1/(2*T1)`` when ``T1`` is set, or ``1/T2`` when ``T1`` is ``None`` (with no T1 subtraction). Returns ``None`` when there is no dephasing channel: either ``T2`` is unset, or ``gamma_phi`` is a *concrete* non-positive scalar. A traced ``gamma_phi`` is kept and clamped via :func:`jax.numpy.maximum`. The construction constraint ``T2 <= 2*T1`` already guarantees ``gamma_phi >= 0`` for concrete inputs, so the clamp only guards traced values. """ if T2 is None: return None gamma_phi = 1.0 / T2 if T1 is not None: gamma_phi = gamma_phi - 1.0 / (2.0 * T1) gamma_phi_value = maybe_concrete_scalar(gamma_phi) if gamma_phi_value is not None and gamma_phi_value <= 0: return None return jnp.maximum(gamma_phi, 0.0) # -- Drive wiring ------------------------------------------------------
[docs] def connect(self, drive: "BaseDrive") -> None: """Register a drive as connected, idempotently on identity and replacing it on relabel. Parameters ---------- drive : BaseDrive Drive object to attach. A drive's label is its stable identity as a control line (``chip.wire`` already rejects duplicate labels within one equipment). Clone-and-rewire flows — ``chip.clone()``, ``eliminate()``'s equipment reattachment, ``chip.partition()`` — build a *fresh* drive object bound to the same label when re-wiring a device that already carries a connected drive, so a same-label, different-object entry marks a stale copy of the same line rather than a second physical line. That stale entry is replaced in place (position preserved); a drive with a distinct label is always appended as an independent line. """ for i, existing in enumerate(self._connected_drives): if existing is drive: return if existing.label == drive.label: self._connected_drives[i] = drive return self._connected_drives.append(drive)
def __repr__(self) -> str: return ( f"{type(self).__name__}(label={self.label!r}, " f"freq={getattr(self, 'freq', None)!r}, levels={self.levels})" )
def _validate_noise_params( T1: float | None, T2: float | None, thermal_occupation: float | None, ) -> None: """Validate T1 / T2 / thermal_occupation on concrete scalars only (JAX-safe).""" T1_value = maybe_concrete_scalar(T1) T2_value = maybe_concrete_scalar(T2) thermal_value = maybe_concrete_scalar(thermal_occupation) if T1_value is not None and T1_value <= 0: raise ValueError(f"T1 must be positive, got {T1}") if T2 is not None: if T2_value is not None and T2_value <= 0: raise ValueError(f"T2 must be positive, got {T2}") if T1_value is not None and T2_value is not None and T2_value > 2 * T1_value: raise ValueError( f"T2 must satisfy T2 <= 2*T1; got T2={T2}, T1={T1} (implied gamma_phi would be negative)" ) if thermal_value is not None and thermal_value < 0: raise ValueError( f"thermal_occupation must be non-negative, got {thermal_occupation}" )