"""Declarative parameter sweeps for chip studies.
Sweep axes compose as Cartesian products or zipped correlated scans. Spectrum
sweeps dress the chip at each point, keeping eigenvalues and bare→dressed label
assignments.
Sweep values keep their native physical units (GHz for frequencies, ns for
times, mK for temperatures). :class:`Sweep` keeps JAX arrays verbatim, so
downstream code can differentiate through swept values. :class:`Sweep` promotes
all other values to ``np.ndarray``, so ``len`` and indexing behave predictably.
The dressed-spectrum machinery here computes the eigenvalues and overlaps of
the lab-frame chip Hamiltonian. For a general reference on the dressed-state
picture of coupled circuit qubits, see Blais, Grimsmo, Girvin & Wallraff,
*Circuit quantum electrodynamics*, Rev. Mod. Phys. 93, 025005 (2021).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
import numpy as np
from quchip.utils.batching import expand_axis_groups
from quchip.utils.labeling import bare_label_from_mapping, top_components
if TYPE_CHECKING:
from quchip.chip.chip import Chip
[docs]
class ZippedSweep:
"""Pair sweep axes element by element.
Use :meth:`Sweep.zip` to validate lengths before you construct this bundle.
Parameters
----------
sweeps : tuple of Sweep
Nonempty axes with equal lengths and distinct names.
Attributes
----------
sweeps : tuple of Sweep
Axes advanced together.
size : int
Length of the first axis.
"""
def __init__(self, sweeps: tuple[Sweep, ...]) -> None:
self.sweeps = sweeps
self.size = sweeps[0].size
def __repr__(self) -> str:
names = [s.name for s in self.sweeps]
return f"ZippedSweep({names}, size={self.size})"
[docs]
class Sweep:
"""Declare a one-dimensional parameter axis.
Parameters
----------
values : array_like, shape (n,)
Values in the parameter's units. JAX arrays are kept and other inputs
are converted to NumPy arrays. Scalar inputs are unsupported.
name : str or None, default None
Parameter path, e.g. ``"q.freq"``. Defaults to ``"unnamed"``. Names
must be unique when axes are combined.
Attributes
----------
values : array_like
Stored axis values.
name : str
Parameter path or axis name.
size : int
Number of values.
"""
def __init__(self, values: Any, *, name: str | None = None) -> None:
from quchip.utils.jax_utils import is_jax_array
self.values = values if is_jax_array(values) else np.asarray(values)
self.name = name or "unnamed"
self.size = len(self.values)
def __repr__(self) -> str:
return f"Sweep({self.name!r}, size={self.size})"
[docs]
@staticmethod
def zip(*sweeps: Sweep) -> ZippedSweep:
"""Pair axes for element-wise iteration.
Requires at least two sweeps of equal size, and raises
:class:`ValueError` otherwise.
Parameters
----------
*sweeps
Two or more :class:`Sweep` axes. Their ``name`` attributes must all
be distinct. A :class:`ZippedSweep` with repeated axis names raises
:class:`ValueError` when it is enumerated later (:meth:`expand`,
:class:`SpectrumSweep`).
Returns
-------
ZippedSweep
Bundle iterated element-wise instead of taken in Cartesian
product with other axes.
"""
if len(sweeps) < 2:
raise ValueError(f"Sweep.zip() requires at least two sweeps, got {len(sweeps)}")
sizes = {s.size for s in sweeps}
if len(sizes) != 1:
raise ValueError(f"Zipped sweeps must have equal lengths, got {sorted(sizes)}")
return ZippedSweep(sweeps)
[docs]
@staticmethod
def expand(axes: Sequence[Sweep | ZippedSweep]) -> list[dict[str, Any]]:
"""Expand independent axes as a Cartesian product and zipped axes pairwise.
Parameters
----------
axes : sequence of Sweep or ZippedSweep
Axes in output order. Names must be unique across all groups.
Returns
-------
list of dict
Parameter mappings in C order (last axis fastest). No axes gives
one empty mapping, and an empty axis gives no points.
"""
_shape, points = _iter_axis_points(axes)
return [params for _coord, params in points]
def _axis_groups(
axes: Sequence[Sweep | ZippedSweep],
) -> list[list[dict[str, Any]]]:
"""Normalize axes into independent groups of parameter dictionaries."""
groups: list[list[dict[str, Any]]] = []
for axis in axes:
if isinstance(axis, ZippedSweep):
groups.append([{s.name: s.values[i] for s in axis.sweeps} for i in range(axis.size)])
else:
groups.append([{axis.name: value} for value in axis.values])
return groups
def _check_unique_axis_names(axes: Sequence[Sweep | ZippedSweep]) -> None:
"""Reject duplicate names across independent axes and zipped members."""
names: list[str] = []
for axis in axes:
if isinstance(axis, ZippedSweep):
names.extend(s.name for s in axis.sweeps)
else:
names.append(axis.name)
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"Sweep axis names must be unique across all axes; duplicated: {duplicates}")
def _iter_axis_points(
axes: Sequence[Sweep | ZippedSweep],
) -> tuple[tuple[int, ...], list[tuple[tuple[int, ...], dict[str, Any]]]]:
"""Yield every (N-D coordinate, param dict) pair on the sweep grid."""
_check_unique_axis_names(axes)
return expand_axis_groups(_axis_groups(axes))
def _axis_metadata(
axes: Sequence[Sweep | ZippedSweep],
rename: Callable[[str], str] = str,
) -> tuple[tuple[str, Any], ...]:
"""Return named grid metadata for ordinary and zipped sweep axes."""
metadata: list[tuple[str, Any]] = []
for axis in axes:
if isinstance(axis, ZippedSweep):
names = tuple(rename(sweep.name) for sweep in axis.sweeps)
values = tuple(
{
name: sweep.values[index]
for name, sweep in zip(names, axis.sweeps, strict=True)
}
for index in range(axis.size)
)
metadata.append(("/".join(names), values))
else:
metadata.append((rename(axis.name), axis.values))
return tuple(metadata)
[docs]
@dataclass
class SpectrumSweepResult:
"""Dressed-spectrum data collected across a :class:`SpectrumSweep` grid.
Attributes
----------
device_labels
Device labels of the underlying chip, in the chip's tensor-product
order (:attr:`~quchip.chip.chip.Chip.devices`).
bare_labels
Bare-state labels tracked by the sweep (one tuple of occupation numbers
per device).
eigenvalues
``(*grid_shape, n_evals)`` array of eigenvalues in GHz, one row
per sweep point.
dressed_indices
``(*grid_shape, n_bare)`` array giving the dressed-state index
assigned to each bare label at each sweep point, or ``NaN``
when no confident assignment is found.
assignment_overlaps
``(*grid_shape, n_bare)`` overlap weights accompanying
``dressed_indices``.
overlap_threshold
Minimum overlap below which an assignment is unreliable and returned as
``NaN``.
params
Object array of parameter dicts, one per grid point.
eigenvector_matrices
Optional object array of dressed eigenvector matrices (one per
grid point), produced when ``store_eigenstates=True``.
eigenstates
Optional object array of dressed eigenstates aligned with
``eigenvector_matrices``.
"""
device_labels: tuple[str, ...]
bare_labels: tuple[tuple[int, ...], ...]
eigenvalues: np.ndarray
dressed_indices: np.ndarray
assignment_overlaps: np.ndarray
overlap_threshold: float
params: np.ndarray
eigenvector_matrices: np.ndarray | None = None
eigenstates: np.ndarray | None = None
@property
def shape(self) -> tuple[int, ...]:
"""Sweep-grid shape (excludes the trailing eigenvalue axis)."""
return self.eigenvalues.shape[:-1]
def _normalize_point(self, point: int | tuple[int, ...] | None) -> tuple[int, ...]:
if self.shape == ():
return ()
if point is None:
raise ValueError(
f"This result spans a sweep grid of shape {self.shape}; pass a "
"point index (int for 1-D sweeps, tuple of ints otherwise) to "
"select which grid point to read."
)
normalized = point if isinstance(point, tuple) else (point,)
if len(normalized) != len(self.shape):
raise ValueError(
f"Point {point!r} has {len(normalized)} coordinate(s) but this result's sweep "
f"grid has shape {self.shape} ({len(self.shape)} axes); pass a coordinate tuple "
"matching the grid's dimensionality."
)
return normalized
def _normalize_label(
self,
device_states: Mapping[str, int] | None,
device_state_kwargs: Mapping[str, int],
) -> tuple[int, ...]:
"""Build a bare-state tuple from device-or-label keys, defaulting to level zero.
Duplicate or unknown labels raise ``ValueError``.
"""
return bare_label_from_mapping(self.device_labels, device_states, device_state_kwargs)
def _bare_label_position(self, label: tuple[int, ...]) -> int:
"""Return the column index of ``label`` in ``bare_labels`` or raise."""
try:
return self.bare_labels.index(label)
except ValueError:
raise ValueError(f"Bare-state label {label} is not present in this sweep") from None
[docs]
def dressed_index(
self,
device_states: Mapping[str, int] | None = None,
/,
**device_state_kwargs: int,
) -> np.ndarray:
"""Dressed-state indices across the sweep for one bare label.
Entries where the assignment overlap falls below
``overlap_threshold`` (or was never found) are returned as
``NaN``.
Parameters
----------
device_states
Mapping from device (label or object) to occupation number, resolved
through :func:`~quchip.utils.labeling.bare_label_from_mapping`.
Unspecified devices default to Fock index 0. Do not use with
``device_state_kwargs``.
**device_state_kwargs
Keyword form of ``device_states`` (device label as keyword).
Returns
-------
numpy.ndarray
Floating-point array of shape :attr:`shape` (the sweep grid,
excluding the bare-label axis). A floating dtype is needed to
represent unreliable or missing assignments as ``NaN``. An
unreliable assignment is a grid point whose overlap with this bare
label falls below ``overlap_threshold``. A missing assignment is a
grid point where no dressed state was assigned to this bare label.
Examples
--------
>>> # Track the bare |1, 0> state across a sweep
>>> # result.dressed_index({qubit_a: 1, qubit_b: 0}) # doctest: +SKIP
"""
label = self._normalize_label(device_states, device_state_kwargs)
bare_pos = self._bare_label_position(label)
indices = np.asarray(self.dressed_indices[..., bare_pos], dtype=float)
overlaps = np.asarray(self.assignment_overlaps[..., bare_pos], dtype=float)
invalid = np.isnan(indices) | (overlaps < self.overlap_threshold)
result = indices.copy()
result[invalid] = np.nan
return result
[docs]
def energy_by_bare_label(
self,
device_states: Mapping[str, int] | None = None,
/,
**device_state_kwargs: int,
) -> np.ndarray:
"""Dressed energies (GHz) traced out for one bare label across the sweep.
Invalid points inherit the ``NaN`` mask from
:meth:`dressed_index`.
Parameters
----------
device_states
Mapping from device (label or object) to occupation number. See
:meth:`dressed_index`. Do not use with ``device_state_kwargs``.
**device_state_kwargs
Keyword form of ``device_states`` (device label as keyword).
Returns
-------
numpy.ndarray
Floating-point array of shape :attr:`shape`, in GHz. Entries
where :meth:`dressed_index` returns ``NaN`` (overlap below
``overlap_threshold``, or no assignment found) are ``NaN``.
"""
indices = self.dressed_index(device_states, **device_state_kwargs)
flat_indices = indices.reshape(-1)
flat_evals = self.eigenvalues.reshape(-1, self.eigenvalues.shape[-1])
flat_out = np.full(flat_indices.shape, np.nan, dtype=float)
valid = ~np.isnan(flat_indices)
flat_out[valid] = flat_evals[np.nonzero(valid)[0], flat_indices[valid].astype(int)]
return flat_out.reshape(indices.shape)
[docs]
def state_components_at(
self,
point: int | tuple[int, ...] | None,
state: int | Mapping[str, int] | None = None,
/,
*,
n_components: int = 5,
**device_state_kwargs: int,
) -> dict[tuple[int, ...], float]:
"""Return the largest bare-state probabilities of one dressed state.
Requires running the sweep with ``store_eigenstates=True``.
Parameters
----------
point : int, tuple of int, or None
Grid coordinate. An integer selects a one-dimensional sweep.
``None`` is valid only for a result with no sweep axes.
state : int, mapping, or None, default None
Dressed index, or a mapping from device object or label to bare
level. A mapping is resolved through the stored overlap assignment.
Omitted devices have level zero.
n_components : int, default 5
Positive maximum number of components returned.
**device_state_kwargs : int
Bare levels keyed by device label, as an alternative to a mapping.
Do not combine with a nonempty mapping.
Returns
-------
dict
Bare occupation tuples mapped to squared amplitudes, in descending order.
Raises
------
ValueError
Eigenvectors were not saved, a coordinate is invalid, the requested
count is nonpositive, or the bare label has no confident assignment.
See Also
--------
SpectrumSweep : Dressed-spectrum conventions and physics reference.
"""
if self.eigenvector_matrices is None:
raise ValueError(
"Eigenvector matrices were not stored for this sweep. "
"Re-run it with store_eigenstates=True."
)
if n_components <= 0:
raise ValueError(f"n_components must be positive, got {n_components}")
point_index = self._normalize_point(point)
eigenvector_matrix = self.eigenvector_matrices[point_index]
if isinstance(state, int) and not isinstance(state, bool):
dressed_idx: int = state
else:
if state is not None and not isinstance(state, Mapping):
raise TypeError(f"state must be an int or mapping, got {type(state).__name__}")
label = self._normalize_label(state, device_state_kwargs)
bare_pos = self._bare_label_position(label)
raw_idx = self.dressed_indices[point_index + (bare_pos,)]
overlap = self.assignment_overlaps[point_index + (bare_pos,)]
if np.isnan(raw_idx) or float(overlap) < self.overlap_threshold:
raise ValueError(
f"No confidently labeled dressed state for bare label {label} at point {point_index}"
)
dressed_idx = int(raw_idx)
return top_components(eigenvector_matrix, self.bare_labels, dressed_idx, n_components)
[docs]
class SpectrumSweep:
"""Calculate dressed spectra over a parameter grid.
Each point uses a fresh :meth:`Chip.with_params` clone and an independent
bare-state overlap assignment, so labels are not transported along the
path.
Parameters
----------
chip : Chip
Model to dress in the lab frame.
axes : sequence of Sweep or ZippedSweep
Nonempty axes named by paths in ``chip.parameters``. Independent axes
form a Cartesian product, and zipped axes advance together.
evals_count : int or None, default None
Number of lowest eigenvalues stored, from 1 to ``chip.total_dim``.
``None`` stores all. Dressing still computes the full eigensystem.
store_eigenstates : bool, default False
Keep eigenvectors and backend states for component inspection.
overlap_threshold : float, default 0.5
Minimum squared overlap for a confident bare-state assignment.
Use a value between 0 and 1. Unassigned labels yield ``NaN``.
References
----------
Blais et al., *Circuit quantum electrodynamics*, Rev. Mod. Phys. 93,
025005 (2021), https://doi.org/10.1103/RevModPhys.93.025005.
The reference describes dressed states; grid traversal is quchip policy.
"""
def __init__(
self,
chip: "Chip",
axes: Sequence[Sweep | ZippedSweep],
*,
evals_count: int | None = None,
store_eigenstates: bool = False,
overlap_threshold: float = 0.5,
) -> None:
self.chip = chip
self.axes = list(axes)
self.evals_count = evals_count
self.store_eigenstates = bool(store_eigenstates)
self.overlap_threshold = float(overlap_threshold)
[docs]
def run(self, progress: bool = True) -> SpectrumSweepResult:
"""Execute the sweep and collect the dressed-spectrum analysis.
Parameters
----------
progress
If true, display a ``tqdm`` progress bar.
Returns
-------
SpectrumSweepResult
Populated result.
"""
from tqdm import tqdm
for axis in self.axes:
if axis.size == 0:
name = axis.name if isinstance(axis, Sweep) else [s.name for s in axis.sweeps]
raise ValueError(
f"Sweep axis {name!r} has zero length; SpectrumSweep.run() cannot iterate an empty grid."
)
shape, expanded = _iter_axis_points(self.axes)
bare_labels = self.chip._canonical_bare_labels()
n_bare = len(bare_labels)
total_dim = self.chip.total_dim
if self.evals_count is None:
n_evals = total_dim
else:
n_evals = self.evals_count
if isinstance(n_evals, bool) or not isinstance(n_evals, int) or not (1 <= n_evals <= total_dim):
raise ValueError(
f"evals_count must be an integer in [1, {total_dim}] (chip.total_dim), got {n_evals!r}"
)
param_store = np.empty(shape, dtype=object)
dressed_indices = np.full(shape + (n_bare,), np.nan, dtype=float)
assignment_overlaps = np.full(shape + (n_bare,), np.nan, dtype=float)
matrices_store = np.empty(shape, dtype=object) if self.store_eigenstates else None
states_store = np.empty(shape, dtype=object) if self.store_eigenstates else None
eigenvalues_store: list[np.ndarray] = []
iterator = tqdm(expanded, desc="SpectrumSweep") if progress else expanded
for coord, params in iterator:
chip_point = self.chip.with_params(params)
dressed = chip_point.dress(overlap_threshold=self.overlap_threshold)
evals = np.asarray(dressed.eigenvalues, dtype=float)[:n_evals]
eigenvalues_store.append(evals)
param_store[coord] = dict(params)
for bare_pos, bare_label in enumerate(bare_labels):
dressed_idx = dressed.state_map.get(bare_label)
if dressed_idx is None or dressed_idx >= len(evals):
continue
dressed_indices[coord + (bare_pos,)] = dressed_idx
assignment_overlaps[coord + (bare_pos,)] = dressed.assignment_overlaps[bare_label]
if matrices_store is not None and states_store is not None:
matrices_store[coord] = np.asarray(dressed.eigenvector_matrix[:, : len(evals)])
states_store[coord] = list(dressed.eigenstates[: len(evals)])
if shape:
eigenvalues = np.asarray(eigenvalues_store, dtype=float).reshape(shape + (len(eigenvalues_store[0]),))
else:
eigenvalues = np.asarray(eigenvalues_store[0], dtype=float)
return SpectrumSweepResult(
device_labels=tuple(device.label for device in self.chip.devices),
bare_labels=bare_labels,
eigenvalues=eigenvalues,
dressed_indices=dressed_indices,
assignment_overlaps=assignment_overlaps,
overlap_threshold=self.overlap_threshold,
params=param_store,
eigenvector_matrices=matrices_store,
eigenstates=states_store,
)