"""Declarative parameter sweeps for chip studies.
This module provides the user-facing sweep abstractions and a
dressed-spectrum sweep driver:
- :class:`Sweep` declares a 1-D axis of values for a single named
parameter. Multiple :class:`Sweep` axes compose as a Cartesian
product.
- :class:`ZippedSweep` (built with :meth:`Sweep.zip`) pairs axes
element-wise rather than expanding the product, which is the right
shape for correlated scans (e.g. frequency and drive amplitude moved
together along a calibration trace).
- :class:`SpectrumSweep` walks a sweep grid, dresses the chip at each
point via :meth:`Chip.dress`, and records eigenvalues and bare→dressed
label assignments into a :class:`SpectrumSweepResult`.
Sweep values are stored in their native physical units (GHz for
frequencies, ns for times, mK for temperatures). :class:`Sweep`
preserves JAX arrays verbatim so downstream code can differentiate
through swept values when desired; everything else is promoted to
``np.ndarray`` so ``len`` and indexing behave predictably.
The dressed-spectrum machinery here computes 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.labeling import bare_label_from_mapping, top_components
if TYPE_CHECKING:
from quchip.chip.chip import Chip
[docs]
class ZippedSweep:
"""Element-wise pairing of sweep axes.
Built via :meth:`Sweep.zip`. All bundled axes must share the same
``size``; iteration steps through them together, producing one
parameter dict per element rather than a Cartesian-product grid.
"""
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:
"""Declarative 1-D sweep axis over a named parameter.
``values`` are in the swept parameter's own physical units (the
package-wide contract: GHz for frequencies and couplings, ns for
times, mK for temperatures).
``values`` may be a Python sequence, a NumPy array, or a JAX array;
JAX arrays are preserved as-is so any consumer that threads the
sweep through a JAX-traced path keeps full differentiability.
Non-JAX inputs are normalized through :func:`numpy.asarray` for
uniform ``len``/indexing behavior.
Examples
--------
>>> import numpy as np
>>> from quchip.sweep import Sweep
>>> freqs = Sweep(np.linspace(4.9, 5.1, 5), name="freq")
>>> freqs.size
5
>>> drives = Sweep([0.01, 0.02, 0.03], name="amp")
>>> points = Sweep.expand([freqs, drives])
>>> len(points)
15
"""
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.
At least two sweeps are required; all sweeps must have equal size.
A :class:`ValueError` is raised otherwise.
Parameters
----------
*sweeps
Two or more :class:`Sweep` axes to bundle element-wise. Their
``name`` attributes must all be distinct — a :class:`ZippedSweep`
with repeated axis names raises :class:`ValueError` when it is
later enumerated (: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 sweep axes into a flat list of parameter dicts.
The Cartesian product of independent :class:`Sweep` axes is
taken; :class:`ZippedSweep` bundles remain element-wise. The
return order is the grid's C-order (last axis varies fastest).
This is the params-only view of :func:`_iter_axis_points` — the
single enumeration code path — with the grid coordinates dropped.
Parameters
----------
axes
:class:`Sweep` and/or :class:`ZippedSweep` axes. Every axis name
(including each member of a zipped bundle) must be unique across
``axes``, else :class:`ValueError` is raised — a repeated name
would silently overwrite itself in each point's parameter dict.
Returns
-------
list[dict[str, Any]]
One parameter dict per grid point, length equal to the product
of the independent axes' sizes (zipped bundles contribute their
shared size once).
"""
_shape, points = _iter_axis_points(axes)
return [params for _coord, params in points]
def _axis_groups(
axes: Sequence[Sweep | ZippedSweep],
) -> tuple[list[list[dict[str, Any]]], tuple[int, ...]]:
"""Normalize axes into per-group parameter dicts plus a Cartesian shape."""
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, tuple(len(group) for group in groups)
def _check_unique_axis_names(axes: Sequence[Sweep | ZippedSweep]) -> None:
"""Raise ``ValueError`` if any name repeats across ``axes``, including within a zip.
``_iter_axis_points`` merges each grid point's per-axis param dicts with
``dict.update``, so two axes (or two members of one
:class:`ZippedSweep`, or a zipped member colliding with an independent
axis) sharing a name silently overwrite each other instead of raising.
"""
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)
groups, shape = _axis_groups(axes)
if not shape:
return (), [((), {})]
expanded: list[tuple[tuple[int, ...], dict[str, Any]]] = []
for coord in np.ndindex(*shape):
params: dict[str, Any] = {}
for group_index, entry_index in enumerate(coord):
params.update(groups[group_index][entry_index])
expanded.append((coord, params))
return shape, expanded
[docs]
@dataclass
class SpectrumSweepResult:
"""Dressed-spectrum data collected across a :class:`SpectrumSweep` grid.
Attributes
----------
device_labels
Device labels of the underlying chip, ordered as the chip's
tensor-product order (:attr:`~quchip.chip.chip.Chip.devices`).
bare_labels
Tuple of bare-state labels (one tuple of occupation numbers per
device) that the sweep tracks.
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 treated as
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, ...]:
"""Resolve partial device-state specifications into a full bare-label tuple.
Delegates to :func:`quchip.utils.labeling.bare_label_from_mapping`,
the single canonical spec-to-tuple definition: keys may be device
objects or labels, unspecified devices default to Fock index 0, and
duplicate or unknown labels raise :class:`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 via :func:`~quchip.utils.labeling.bare_label_from_mapping`;
devices left unspecified default to Fock index 0. Mutually
exclusive 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). Floating dtype is required to
represent unreliable or missing assignments as ``NaN`` — a grid
point whose overlap with this bare label falls below
``overlap_threshold``, or where no dressed state was assigned
to it at all.
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`. Mutually exclusive 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]:
"""Top-``n_components`` bare-state probabilities of a dressed eigenstate.
``state`` may either be an explicit dressed index (``int``) or
a bare-label specification that is resolved via the same
overlap-based map used by :meth:`dressed_index`. Requires the
sweep to have been run with ``store_eigenstates=True``.
Returns a dict mapping bare labels to squared-amplitude
probabilities, ordered from largest to smallest.
"""
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:
"""Sequential dressed-spectrum sweep driver.
At each grid point the chip is cloned via :meth:`Chip.updated`,
``update_fn`` mutates the clone with the swept values, and
:meth:`Chip.dress` computes the lab-frame dressed spectrum. The
per-point eigenvalues and overlap-based bare→dressed assignments
are collected into a :class:`SpectrumSweepResult`.
This is the standard tool for two-tone spectroscopy maps, avoided
crossings, and any study that requires following dressed states
across parameter space. See Blais, Grimsmo, Girvin & Wallraff,
Rev. Mod. Phys. 93, 025005 (2021), for the dressed-state picture;
the overlap-based labeling follows the usual practice of assigning
a dressed eigenstate to the bare state with which it has maximum
overlap.
Examples
--------
>>> # Sweep a qubit frequency and record dressed levels
>>> # sweep = SpectrumSweep(
>>> # chip,
>>> # [Sweep(np.linspace(4.8, 5.2, 41), name="freq")],
>>> # update_fn=lambda c, p: setattr(c.device(qubit), "frequency", p["freq"]),
>>> # )
>>> # result = sweep.run() # doctest: +SKIP
"""
def __init__(
self,
chip: "Chip",
axes: Sequence[Sweep | ZippedSweep],
*,
update_fn: Callable[["Chip", dict[str, Any]], None],
evals_count: int | None = None,
store_eigenstates: bool = False,
overlap_threshold: float = 0.5,
) -> None:
self.chip = chip
self.axes = list(axes)
self.update_fn = update_fn
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
Whether to 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] = []
# Snapshotted once, pre-sweep: the label/shape scaffold above
# (bare_labels, n_evals, device_labels on the result) is fixed from
# this signature, so update_fn changing device count, order, labels,
# or levels at any grid point would silently misalign every array
# collected here against a scaffold that no longer matches.
topology_signature = tuple((device.label, device.levels) for device in self.chip.devices)
iterator = tqdm(expanded, desc="SpectrumSweep") if progress else expanded
for coord, params in iterator:
chip_point = self.chip.updated(lambda cloned: self.update_fn(cloned, params))
point_signature = tuple((device.label, device.levels) for device in chip_point.devices)
if point_signature != topology_signature:
raise ValueError(
f"update_fn {self.update_fn!r} changed chip topology at grid point {coord}: "
f"expected devices (label, levels) = {topology_signature}, got {point_signature}."
)
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,
)