Source code for quchip.chip.baths

"""Chip-level baths for shared or collective Lindblad dissipation.

A :class:`Bath` is **not** a device. It owns no Hilbert-space factor and no
Hamiltonian term, only collapse operators that couple a *set* of devices to a
common environment. This layer is for physics *around* devices, e.g. a single
chip temperature at which every device thermalizes. It also covers correlated
or collective dissipation (collective decay, correlated dephasing), which
per-device noise, independent by construction, cannot express.

Rates are in 1/ns (the Lindblad convention), with no 2π scaling, because that
boundary applies only to the Hamiltonian. The thermal Bose factor uses ``k_B``
in GHz/mK, so ``n̄ = 1 / expm1(freq / (k_B * T))`` with ``freq`` in GHz and
``T`` in mK.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING, Any

import jax.numpy as jnp

from quchip.declarative.dissipation import CollapseChannel, normalize_dissipation
from quchip.declarative.expr import ParameterNamespace, PhysicsExpr
from quchip.declarative.ops import LocalOps
from quchip.declarative.parameters import Parameter
from quchip.devices.spaces import FockSpace
from quchip.utils.constants import k_B
from quchip.utils.jax_utils import concrete_array_module, maybe_concrete_scalar
from quchip.utils.labeling import auto_label, resolve_label
from quchip.utils.values import copy_value

if TYPE_CHECKING:
    from quchip.chip.chip import Chip

_BATH_MODELS = ("thermal", "collective_decay", "correlated_dephasing")


def _bose_occupation(temperature: Any, frequency: Any) -> Any:
    """Traceable Bose occupation with the physical zero-temperature limit."""
    is_zero = temperature == 0
    safe_denominator = jnp.where(is_zero, 1.0, k_B * temperature)
    finite = 1.0 / jnp.expm1(frequency / safe_denominator)
    return jnp.where(is_zero, 0.0, finite)


def _adjoint(operator: Any) -> Any:
    return operator.dag() if hasattr(operator, "dag") else concrete_array_module(operator).asarray(operator).conj().T


[docs] class Bath: """A shared environment that couples a set of devices to a common bath. Attach the bath at construction (``Chip(..., baths=[...])``) or later with :meth:`~quchip.chip.chip.Chip.add_bath`. The next simulate or solve call collects the bath's collapse operators automatically. Parameters ---------- recipe : str Built-in collapse-channel model: ``"thermal"``, ``"collective_decay"``, or ``"correlated_dephasing"``. The argument name stays the same for API and serialization compatibility. targets : list[BaseDevice | str] | None Devices the bath couples to (objects or labels). ``None`` (default) means *every* device in the chip, the natural choice for a global thermal bath. temperature : float | None Temperature in mK, required for ``"thermal"``. It can be a JAX tracer for sweeps or gradients. rate : float | None Bath–device coupling rate γ in 1/ns. For ``"thermal"``, it is the environmental coupling rate. It is explicit and never silently borrowed from a device ``T1``, so it cannot double-count device-level noise. For the collective models, it is the overall jump rate. ``None`` defaults to ``1.0`` (the user controls the absolute scale elsewhere). correlated : bool ``"thermal"`` only. ``False`` (default) emits independent per-device channels that share one temperature. ``True`` is not supported and raises :class:`NotImplementedError`. The collective models always emit a single correlated operator, independent of this flag. label : str | None Auto-generated ``"bath_{n}"`` when omitted. Examples -------- >>> from quchip import DuffingTransmon, Chip, Bath >>> q = DuffingTransmon(freq=5.0, anharmonicity=-0.25, levels=3, label="q") >>> chip = Chip([q]) >>> _ = chip.add_bath(Bath("thermal", temperature=20.0)) # global 20 mK bath >>> _ = chip.add_bath(Bath("collective_decay", targets=[q], rate=0.01)) """ _type_prefix = "bath" _parameter_names = ("temperature", "rate") def __init__( self, recipe: str, targets: list[Any] | None = None, *, temperature: Any = None, rate: Any = None, correlated: bool = False, label: str | None = None, ) -> None: if recipe not in _BATH_MODELS: raise ValueError( f"Unknown bath model {recipe!r}. Expected one of {_BATH_MODELS}." ) if recipe == "thermal" and temperature is None: raise ValueError("The 'thermal' bath model requires a temperature (mK).") if recipe == "thermal" and correlated: raise NotImplementedError( "Collective thermal baths are unsupported; use correlated=False " "(independent channels sharing one temperature)." ) self._retained: dict[str, tuple[Any, Any, Any, Any]] | None = None # Removed targets whose retained multiport response the channels omit. self._omitted_responses: tuple[str, ...] = () self.recipe = recipe self._targets = targets self.temperature = temperature self.rate = rate self.label = label if label is not None else auto_label(self._type_prefix) def __setattr__(self, name: str, value: Any) -> None: """Reject a concrete negative ``temperature`` or ``rate`` (construction and later writes). Mirrors the concrete-only validation that :class:`~quchip.devices.base.BaseDevice` and :class:`~quchip.declarative.models.CouplingModel` run on their own fields, so a traced value passes unchecked. """ # Without this validation, a raw `bath.temperature = -5` or # `bath.rate = -1` can cause a negative Bose occupation or an invalid # Lindblad rate in `collapse_channels`. if name in ("temperature", "rate"): concrete = maybe_concrete_scalar(value) if concrete is not None and concrete < 0: raise ValueError(f"{name} must be >= 0, got {value}") super().__setattr__(name, value)
[docs] def resolve_targets(self, chip: "Chip") -> list[str]: """Return ordered target labels, defaulting to all devices. Parameters ---------- chip : Chip Chip that supplies labels when targets were omitted. """ if self._targets is None or self._retained is not None: return [d.label for d in chip.devices] return [resolve_label(t) for t in self._targets]
@property def separable(self) -> bool: """Whether this bath factorizes into independent per-target channels. ``True`` for models that emit one collapse operator per target (``"thermal"`` with independent channels). ``False`` for models that emit a single jump operator summed over targets (``"collective_decay"``, ``"correlated_dephasing"``). Partitioning treats a non-separable bath's target set as one inseparable block. """ return self.recipe == "thermal" and self._retained is None def __repr__(self) -> str: """Return a compact bath-model and target summary.""" targets = "all" if self._targets is None else [resolve_label(t) for t in self._targets] return ( f"Bath(label={self.label!r}, model={self.recipe!r}, " f"temperature={self.temperature}, rate={self.rate}, targets={targets})" )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize the bath, with ``targets`` stored as label strings.""" targets = None if self._targets is None else [resolve_label(t) for t in self._targets] omitted = {"omitted_responses": list(self._omitted_responses)} if self._omitted_responses else {} return { "type": f"{type(self).__module__}.{type(self).__qualname__}", "retained": None if self._retained is None else { label: [float(freq), *[dict(real=jnp.asarray(op).real.tolist(), imag=jnp.asarray(op).imag.tolist()) for op in (lowering, number)]] for label, (freq, lowering, number, _changes) in self._retained.items() }, "recipe": self.recipe, "targets": targets, "temperature": self.temperature, "rate": self.rate, "label": self.label, **omitted, }
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "Bath": """Reconstruct a bath from serialized state. Parameters ---------- d : dict[str, Any] Payload produced by :meth:`to_dict`. Targets are label strings. """ bath = cls( d["recipe"], targets=d.get("targets"), temperature=d.get("temperature"), rate=d.get("rate"), label=d.get("label"), ) if d.get("retained") is not None: bath._retained = {} for label, entry in d["retained"].items(): if not isinstance(label, str) or not label or len(entry) != 3: raise ValueError("A retained bath target needs a label, frequency and two operators.") frequency, *operators = entry matrices = [] for operator in operators: real, imag = jnp.asarray(operator["real"]), jnp.asarray(operator["imag"]) if real.ndim != 2 or real.shape != imag.shape or real.shape[0] != real.shape[1]: raise ValueError("Retained bath operators need square, matching real/imag matrices.") if not bool(jnp.all(jnp.isfinite(real) & jnp.isfinite(imag))): raise ValueError("Retained bath operators must be finite.") matrices.append(real + 1j * imag) if not bool(jnp.isfinite(frequency)) or frequency < 0: raise ValueError("Retained bath frequencies must be finite and nonnegative.") # Concrete operators give their level changes from their entries. bath._retained[label] = (frequency, matrices[0], matrices[1], None) omitted = d.get("omitted_responses", ()) if not set(omitted) <= set(bath._retained or ()): raise ValueError("Omitted bath responses must name retained bath targets.") bath._omitted_responses = tuple(sorted(set(omitted))) return bath
[docs] def physics_notes(self) -> list[str]: """Return human-readable declarations of this bath's model and scope. Mirrors :meth:`~quchip.chip.coupling_base.BaseCoupling.physics_notes`. Returns one entry naming the model and its targets, plus a model-specific assumption that users must know. """ targets = "all devices" if self._targets is None else ", ".join(resolve_label(t) for t in self._targets) notes = [f"Bath model: '{self.recipe}'; targets: {targets}."] if self.recipe == "thermal": notes.append( "Independent per-target thermal channels sharing one bath temperature; " "correlated=True is unsupported." ) elif self.recipe == "collective_decay": notes.append( "Single shared rank-one collective channel L = sum_i a_i with a separate rate " "(equal-phase, equal-weight; not general super/subradiant decay)." ) else: notes.append( "Single shared common-mode dephasing channel L = sum_i n_i with a separate rate " "(maximally correlated; not a general target-dependent correlation structure)." ) return notes
[docs] def copy(self) -> "Bath": """Independent copy of this bath (targets normalize to label strings). ``Chip.clone`` and ``eliminate`` use this method, so a transformed chip never shares live ``Bath`` objects with its source. Parameter values (temperature, rate) are carried by reference, so traced values stay traced. """ # Changing one chip's bath must not silently change another chip's # physics. result = Bath(self.recipe, None if self._targets is None else [resolve_label(t) for t in self._targets], temperature=copy_value(self.temperature), rate=copy_value(self.rate), label=self.label) result._retained = copy_value(self._retained, readonly=True) result._omitted_responses = self._omitted_responses return result
[docs] def parameter_values(self) -> dict[str, Any]: """Return active bath values by local field name.""" return { name: value for name in self._parameter_names if (value := getattr(self, name)) is not None }
[docs] def set_parameter_value(self, name: str, value: Any) -> None: """Apply one bath value on an isolated bath copy. Parameters ---------- name : {"temperature", "rate"} Local parameter name. value : Any Temperature in mK or rate in 1/ns. """ if name not in self._parameter_names: raise KeyError(name) setattr(self, name, value)
def _resolved_bases(self, chip: "Chip", bases: Mapping[str, Any] | None) -> Mapping[str, Any]: """Return supplied engine bases or resolve them for direct inspection.""" if bases is not None: return bases from quchip.engine.basis import resolve_device_basis return { device.label: resolve_device_basis( device, basis=chip.resolve_basis(device), levels=( device.resolved_dimension(chip.basis) if chip.resolve_basis(device) == "eigen" else None ), ) for device in chip.devices } @staticmethod def _semantic_operator(record: Any, kind: str, xp: Any) -> Any: """Express an energy-ordered lowering or number operator in authored coordinates.""" dimension = record.resolved_dim if kind == "lowering": semantic = xp.diag(xp.sqrt(xp.arange(1, dimension)), 1).astype(complex) else: semantic = xp.diag(xp.arange(dimension)).astype(complex) vectors = xp.asarray(record.energy_vectors) return vectors @ semantic @ vectors.conj().T def _operator_expr( self, device: Any, record: Any, kind: str, xp: Any, ) -> PhysicsExpr: """Author a bath operator in the device's declared local coordinates.""" space = device.local_space() if isinstance(space, FockSpace): op = LocalOps(label=device.label, space=space, device=device) if kind == "lowering": return op.a if kind == "raising": return op.adag return op.n semantic_kind = "lowering" if kind == "raising" else kind matrix = self._semantic_operator(record, semantic_kind, xp) if kind == "raising": matrix = matrix.conj().T return PhysicsExpr.from_matrix( matrix, labels=(device.label,), dims=(record.native_dim,), name=rf"\hat L_{{{self.label},{device.label}}}", )
[docs] def dissipation( self, chip: "Chip", bases: Mapping[str, Any] | None = None, ) -> tuple[CollapseChannel, ...]: """Return authored full-chip collapse channels. ``"thermal"`` emits independent per-target relaxation/absorption pairs that share one bath temperature. The two collective models each emit a single jump operator summed over the resolved targets: - ``"collective_decay"``: ``L = sum_i a_i`` at rate ``gamma``, an equal-phase, equal-weight rank-one collective channel. It is *not* general collective (super/subradiant) decay, which requires per-pair phase and weight factors set by the target geometry (Lehmberg, *Phys. Rev. A* **2**, 883 (1970), for the general collective-radiative-decay construction). - ``"correlated_dephasing"``: ``L = sum_i n_i`` at rate ``gamma``, i.e. maximally correlated common-mode dephasing, where every target shares the identical dephasing fluctuation. It is *not* general correlated dephasing with a target-dependent correlation structure (Breuer & Petruccione, *The Theory of Open Quantum Systems*, Oxford, 2002, Ch. 3, for the general Lindblad construction). Contributions stay backend-neutral. Parameters ---------- chip : Chip Chip whose targets and tensor-product order define the operators. bases : mapping or None, default=None Captured basis records keyed by device label. ``None`` resolves them. """ # The thermal Bose occupation is computed in `_bose`. # # The engine projects and lowers them with the same basis records it # uses for Hamiltonian terms. fields = {name: Parameter() for name in self._parameter_names} p = ParameterNamespace(f"bath.{self.label}", fields) gamma = 1.0 if self.rate is None else p.rate terms: list[CollapseChannel] = [] summed: PhysicsExpr | None = None for label, frequency, lowering, number in self._target_operators(chip, bases): if self.recipe == "thermal": n_bar = PhysicsExpr.from_function( _bose_occupation, p.temperature, PhysicsExpr.literal(frequency), labels=(), dims=(), name="n_bar", ) emission, absorption = self._channel_names(label) terms.extend(( CollapseChannel(lowering, gamma * (n_bar + 1), emission), CollapseChannel(PhysicsExpr.from_function( _adjoint, lowering, labels=lowering.labels, dims=chip.authored_dims, name="raising", ), gamma * n_bar, absorption), )) else: operator = lowering if self.recipe == "collective_decay" else number summed = operator if summed is None else summed + operator if summed is not None: terms.append(CollapseChannel(summed, gamma, self.recipe)) return tuple(terms)
def _channel_names(self, label: str) -> tuple[str, ...]: """Return the names of the channels that act on target *label*.""" if self.recipe == "thermal": return f"thermal_emission:{label}", f"thermal_absorption:{label}" return (self.recipe,) def _target_operators(self, chip: "Chip", bases: Mapping[str, Any] | None = None) -> Any: """Yield each bath target before summing collective amplitudes or applying rates.""" labels, dims = tuple(d.label for d in chip.devices), chip.authored_dims if self._retained is not None: for label, (frequency, lowering, number, changes) in self._retained.items(): if label in chip.device_map: frequency = getattr(chip[label], "freq", frequency) yield (label, frequency, *[ PhysicsExpr.from_matrix(op, labels=labels, dims=dims, name=f"{self.label}:{label}", excitation_changes=change) for op, change in zip((lowering, number), changes or (None, None)) ]) return records = self._resolved_bases(chip, bases) for label in self.resolve_targets(chip): device = chip[label] yield (label, getattr(device, "freq", 0.0), *[ self._operator_expr(device, records[label], kind, jnp).embed(labels, dims) for kind in ("lowering", "number") ]) def _collapse_channels_with_paths( self, chip: "Chip", bases: Mapping[str, Any] | None = None, ) -> tuple[tuple[CollapseChannel, tuple[str, ...]], ...]: fields = {name: Parameter() for name in self._parameter_names} return normalize_dissipation( self.dissipation(chip, bases), labels=tuple(device.label for device in chip.devices), dims=chip.authored_dims, owner=self, scope=f"bath.{self.label}", allowed=fields, bindings={ f"bath.{self.label}.{name}": value for name in fields if (value := getattr(self, name)) is not None }, )
[docs] def collapse_channels( self, chip: "Chip", bases: Mapping[str, Any] | None = None, ) -> tuple[CollapseChannel, ...]: """Return normalized full-chip bath channels. Parameters ---------- chip : Chip Chip whose targets define the channels. bases : mapping or None, default=None Captured basis records, or ``None`` to resolve them. """ return tuple( channel for channel, _paths in self._collapse_channels_with_paths(chip, bases) )