Source code for quchip.analysis.cross_resonance

"""Cross-resonance Hamiltonian tomography analysis.

Fits the target qubit's Bloch-vector trajectories from a CR pulse-duration
sweep. Extracts the six effective Hamiltonian coefficients {IX, IY, IZ, ZX, ZY,
ZZ} in GHz (the package's units contract).

The CR effective Hamiltonian in the two-qubit subspace is:

    H_eff = (I ⊗ A + Z ⊗ B) / 2,   A, B ∈ span{X, Y, Z}

which expands to six coefficients:

    ω_IX, ω_IY, ω_IZ  (control-state-independent)
    ω_ZX, ω_ZY, ω_ZZ  (control-state-dependent)

in units of ordinary frequency (GHz).

References
----------
Sheldon, Magesan, Chow, Gambetta, "Procedure for systematically tuning up
cross-talk in the cross resonance gate", PRA 93, 060302(R) (2016).

Unit convention: public ``durations`` are in **ns** and returned
coefficients are in **GHz**. The fit runs internally in Hz/seconds (the
conditioning heuristics are tuned for CR-tomography scales, following
Sheldon et al.); both conversions
happen exactly once at the public boundary. Multiply by 2π to get angular
frequency.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import numpy as np
from scipy.optimize import least_squares
from scipy.signal import savgol_filter

from quchip.utils.labeling import resolve_label

if TYPE_CHECKING:
    from quchip.chip.chip import Chip
    from quchip.control.drive import BaseDrive
    from quchip.devices.base import BaseDevice


# ---------------------------------------------------------------------------
# Bloch-trajectory model
# ---------------------------------------------------------------------------


[docs] def bloch_model( t: np.ndarray, px: float, py: float, pz: float, td: float, bx: float, by: float, bz: float, ) -> np.ndarray: r"""Model the Bloch-vector evolution under a constant drive Hamiltonian with a single decay envelope. The model assumes a drive Hamiltonian H = π (px X + py Y + pz Z) so the Bloch vector precesses at frequency :math:`f = \sqrt{p_x^2 + p_y^2 + p_z^2}` Hz about the axis :math:`\hat{n} = (p_x, p_y, p_z)/f`. A single exponential envelope :math:`\exp(-t / t_d)` damps all three Bloch components equally, without separate :math:`T_1` and :math:`T_2` decay processes. Parameters ---------- t : (N,) ndarray Times in seconds. px, py, pz : float Drive Hamiltonian coefficients in Hz (ordinary frequency). td : float Decay time constant in seconds. bx, by, bz : float Bloch vector offset (fixed point, reached asymptotically). Returns ------- (3, N) ndarray Rows are ``[⟨X(t)⟩, ⟨Y(t)⟩, ⟨Z(t)⟩]``. """ f = np.sqrt(px * px + py * py + pz * pz) env = np.exp(-t / max(td, 1e-30)) if f < 1e-12: return np.vstack([ bx * np.ones_like(t), by * np.ones_like(t), env + bz, ]) w = 2 * np.pi * f nx, ny, nz = px / f, py / f, pz / f c, s = np.cos(w * t), np.sin(w * t) return np.vstack([ (ny * s + nx * nz * (1 - c)) * env + bx, (-nx * s + ny * nz * (1 - c)) * env + by, (nz * nz + (1 - nz * nz) * c) * env + bz, ])
# --------------------------------------------------------------------------- # Fitting internals # --------------------------------------------------------------------------- def _residuals(p, t, dx, dy, dz, wx, wy, wz): m = bloch_model(t, *p) return np.concatenate([(m[0] - dx) * wx, (m[1] - dy) * wy, (m[2] - dz) * wz]) def _estimate_frequency(x: np.ndarray, y: np.ndarray) -> float: """Estimate oscillation frequency (Hz) from an irregularly-sampled series. Uses FFT on a uniformly re-sampled copy, with a Savitzky-Golay derivative fallback for low-frequency signals that are poorly resolved by the FFT. """ x = np.asarray(x, dtype=float) y = np.asarray(y, dtype=float) intervals = np.diff(x) dt = np.median(intervals) if dt <= 0: return 1e6 if not np.allclose(intervals, dt, rtol=1e-3): x_uniform = np.arange(x[0], x[-1], dt) y = np.interp(x_uniform, x, y) x = x_uniform n = len(x) fft_vals = np.fft.rfft(y - np.mean(y)) freqs = np.fft.rfftfreq(n, dt) if len(fft_vals) < 2: return 1e6 magnitudes = np.abs(fft_vals[1:]) freq_guess = freqs[1:][np.argmax(magnitudes)] freq_resolution = 1.0 / (dt * n) if freq_guess < 1.5 * freq_resolution and n >= 5: wlen = min(n, max(5, n // 4)) if wlen % 2 == 0: wlen -= 1 if wlen >= 3: y_smooth = savgol_filter(y, window_length=wlen, polyorder=min(2, wlen - 1)) y_amp = np.max(np.abs(y_smooth - np.mean(y_smooth))) if y_amp > 1e-10: max_deriv = np.max(np.abs(np.diff(y_smooth) / dt)) freq_guess = max_deriv / (y_amp * 2 * np.pi) return abs(freq_guess) def _generate_guesses(t, dx, dy, dz): """Multi-start initial guesses: FFT phi-sweep + sign-flip octants.""" guesses = [] omega_list = [] for y_data in [dx, dy, dz]: ymin, ymax = np.percentile(y_data, [10, 90]) if ymax - ymin < 0.2: continue try: omega_list.append(_estimate_frequency(t, y_data)) except Exception: continue fg = np.mean(omega_list) if omega_list else 1e6 zmin, zmax = np.percentile(dz, [10, 90]) z_amp = np.clip((zmax - zmin) / 2, 0, 1) theta = np.arccos(np.sqrt(z_amp)) td_guess = t[-1] * 2 bx_g, by_g = dx.mean(), dy.mean() bz_g = np.mean(dz[-max(1, len(dz) // 5):]) dt_step = t[1] - t[0] if len(t) > 1 else 1.0 df = 1.0 / (dt_step * len(t)) for fg_shifted in [fg, fg - df / 2, fg + df / 2]: for phi in np.linspace(-np.pi, np.pi, 5): guesses.append([ fg_shifted * np.cos(theta) * np.cos(phi), fg_shifted * np.cos(theta) * np.sin(phi), fg_shifted * np.sin(theta), td_guess, bx_g, by_g, bz_g, ]) if fg < df: guesses.append([fg, fg, 0.0, td_guess, bx_g, by_g, bz_g]) ax = (dx.max() - dx.min()) / 2 ay = (dy.max() - dy.min()) / 2 p0_base = [ ay * fg * 0.5 or 0.1 * fg, ax * fg * 0.5 or 0.1 * fg, np.sqrt(max(0, 1 - (dz.max() - dz.min()) / 2)) * fg, td_guess, bx_g, by_g, bz_g, ] for s0, s1, s2 in [ (1, 1, 1), (-1, 1, 1), (1, -1, 1), (1, 1, -1), (-1, -1, 1), (-1, 1, -1), (1, -1, -1), (-1, -1, -1), ]: p = list(p0_base) p[0] *= s0 p[1] *= s1 p[2] *= s2 guesses.append(p) return guesses def _fit_bloch(t, dx, dy, dz, sx=None, sy=None, sz=None): """Fit one control-state Bloch trajectory and retain optimizer status. Parameters ---------- t : (N,) ndarray Durations in seconds. dx, dy, dz : (N,) ndarrays Target Bloch components for one control state. sx, sy, sz : (N,) ndarrays or None Per-point uncertainties (inverse weights). ``None`` → unit weights. Returns ------- tuple Native optimizer result and covariance, or ``None`` when uncertainty is unavailable. The fitted vector is [px, py, pz, td, bx, by, bz]. """ idx = np.argsort(t) t, dx, dy, dz = t[idx], dx[idx], dy[idx], dz[idx] wx = 1.0 / sx[idx] if sx is not None else np.ones_like(t) wy = 1.0 / sy[idx] if sy is not None else np.ones_like(t) wz = 1.0 / sz[idx] if sz is not None else np.ones_like(t) guesses = _generate_guesses(t, dx, dy, dz) lb = [-np.inf, -np.inf, -np.inf, 1e-12, -2, -2, -2] ub = [np.inf, np.inf, np.inf, np.inf, 2, 2, 2] best, best_cost = None, np.inf last_error = None for p0 in guesses: try: r = least_squares( _residuals, p0, args=(t, dx, dy, dz, wx, wy, wz), bounds=(lb, ub), method="trf", max_nfev=5000, ) if r.cost < best_cost: best, best_cost = r, r.cost except (ValueError, FloatingPointError) as error: last_error = error if best is None: raise RuntimeError("No initial guess produced a finite CR fit candidate.") from last_error return best, _fit_covariance(best) def _fit_covariance(result): """Local residual-scaled covariance, unavailable without rank and convergence.""" jac = np.asarray(result.jac) dof = jac.shape[0] - jac.shape[1] if not result.success or dof <= 0 or not np.all(np.isfinite(jac)): return None scales = np.linalg.norm(jac, axis=0) if np.any(scales == 0): return None try: _, singular, vt = np.linalg.svd(jac / scales, full_matrices=False) except np.linalg.LinAlgError: return None if singular[-1] <= np.finfo(float).eps * max(jac.shape) * singular[0]: return None inverse = vt.T / singular / scales[:, None] covariance = (inverse @ inverse.T) * (2 * result.cost / dof) return covariance if np.all(np.isfinite(covariance)) else None # --------------------------------------------------------------------------- # Result container # ---------------------------------------------------------------------------
[docs] @dataclass class CRHamiltonianResult: """Store CR Hamiltonian coefficients in GHz and local uncertainty estimates. The uncertainties use a local, residual-scaled covariance, even when per-point standard deviations supply weights. An unavailable estimate is ``None``. Convergence alone does not establish identifiability. All six quantities are *ordinary* frequency (not angular). Multiply by 2π to convert to rad/ns. The convention agrees with Sheldon et al. (PRA 93, 060302(R), 2016): H_eff = (I ⊗ A + Z ⊗ B) / 2 where A = ω_IX X + ω_IY Y + ω_IZ Z and B = ω_ZX X + ω_ZY Y + ω_ZZ Z. ``params_ctrl0`` / ``params_ctrl1`` hold the raw fit output ``[px, py, pz, td, bx, by, bz]`` for each control state. They are diagnostics only and stay in the fit's internal units (px/py/pz in Hz, td in seconds). Attributes ---------- IX, IY, IZ, ZX, ZY, ZZ : float Fitted ordinary-frequency coefficients in GHz. IX_err, IY_err, IZ_err, ZX_err, ZY_err, ZZ_err : float or None Local residual-scaled uncertainty estimates in GHz. params_ctrl0, params_ctrl1 : ndarray or None Raw fit parameters for the two control states. cov_ctrl0, cov_ctrl1 : ndarray or None Raw local covariance matrices. cost_ctrl0, cost_ctrl1 : float or None Least-squares costs. converged : bool or None Fit convergence status. message : str Solver status message. """ IX: float IY: float IZ: float ZX: float ZY: float ZZ: float IX_err: float | None IY_err: float | None IZ_err: float | None ZX_err: float | None ZY_err: float | None ZZ_err: float | None params_ctrl0: np.ndarray | None = None params_ctrl1: np.ndarray | None = None cov_ctrl0: np.ndarray | None = None cov_ctrl1: np.ndarray | None = None cost_ctrl0: float | None = None cost_ctrl1: float | None = None converged: bool | None = None message: str = ""
[docs] def coeffs(self) -> dict[str, tuple[float, float | None]]: """Return ``{name: (value_ghz, err_ghz)}`` for all six coefficients.""" return {k: (getattr(self, k), getattr(self, k + "_err")) for k in ("IX", "IY", "IZ", "ZX", "ZY", "ZZ")}
def _format(self, header: str) -> str: """Build the per-coefficient MHz summary text under the given header.""" lines = [f"{header} (MHz):"] for k, (v, e) in self.coeffs().items(): uncertainty = "uncertainty unavailable" if e is None else f"+/- {e * 1e3:.4f}" lines.append(f" {k} = {v * 1e3:+.4f} {uncertainty}") status = "unavailable" if self.converged is None else ("converged" if self.converged else "not converged") lines.append(f"Fit status: {status}. {self.message}".rstrip()) return "\n".join(lines)
[docs] def summary(self) -> str: """Print and return a formatted summary of all six coefficients.""" text = self._format("CR Hamiltonian") print(text) return text
def __repr__(self) -> str: return self._format("CRHamiltonianResult")
[docs] @dataclass(frozen=True) class CRSusceptibilityResult: r"""Store weak-drive CR coefficients per unit of programmed drive amplitude. For control-state-conditioned target-transition matrix elements .. math:: m_z = \langle\widetilde{z_c,1_t}|D_c|\widetilde{z_c,0_t}\rangle, the effective-Hamiltonian convention .. math:: H_\mathrm{eff} = \tfrac{1}{2}[\mathrm{Re}(C_I)IX + \mathrm{Im}(C_I)IY + \mathrm{Re}(C_Z)ZX + \mathrm{Im}(C_Z)ZY] gives ``IX_per_amplitude = (m_0 + m_1)/2`` and ``ZX_per_amplitude = (m_0 - m_1)/2`` for a real carrier's peak amplitude. The raw matrix elements retain the full drive operator normalization. Complex values encode ``IX+i IY`` and ``ZX+i ZY`` in the raising convention. Their real and imaginary parts multiply Hermitian X and Y operators. Attributes ---------- m_control_0, m_control_1 : complex or array_like Dressed target-transition matrix elements, conditioned on the control in ``|0>`` and in ``|1>``. IX_per_amplitude, ZX_per_amplitude : complex or array_like Complex weak-drive X/Y coefficients per unit of scheduled peak amplitude. control, target, drive : str Resolved labels. """ m_control_0: Any m_control_1: Any IX_per_amplitude: Any ZX_per_amplitude: Any control: str target: str drive: str
[docs] def analyze_cr_susceptibility( chip: "Chip", control: str | "BaseDevice", target: str | "BaseDevice", *, drive: str | "BaseDrive" | None = None, ) -> CRSusceptibilityResult: r"""Return the dressed weak-drive CR response of one directed edge. The analysis projects the physical control-line operator onto the target's dressed ``0 -> 1`` transition twice, with the control in ``|0>`` and in ``|1>``. It does no pulse, no rotating-frame solve, and no time evolution. Parameters ---------- chip Coupled chip with attached control equipment. control, target Directed CR control and target, as device objects or labels. drive Control line to project. If omitted, the function selects the unique wired device-target line attached to ``control``. Returns ------- CRSusceptibilityResult Conditional matrix elements and the related ``IX`` and ``ZX`` complex X/Y coefficients per unit of scheduled peak amplitude. Carrier selection contributes a factor of one half. Raises ------ ValueError If the edge is a self-edge, control equipment is absent, implicit drive resolution is ambiguous, or the selected line does not target the control. References ---------- Magesan and Gambetta, Phys. Rev. A 101, 052308 (2020). https://arxiv.org/abs/1804.04073. Malekakhlagh, Magesan, and McKay, Phys. Rev. A 102, 042605 (2020). https://arxiv.org/abs/2005.00133. """ _, control_device = chip._resolve_device_index(control) _, target_device = chip._resolve_device_index(target) if control_device is target_device: raise ValueError("control and target must be different devices") equipment = chip.control_equipment if equipment is None: raise ValueError("analyze_cr_susceptibility requires attached control equipment") candidates = [ line for line in equipment.lines if line.device_label == control_device.label ] if drive is None: if len(candidates) != 1: raise ValueError( f"Expected one drive targeting '{control_device.label}', found {len(candidates)}" ) selected = candidates[0] else: selected_label = resolve_label(drive) matches = [line for line in equipment.lines if line.label == selected_label] if len(matches) != 1: raise ValueError(f"Drive '{selected_label}' is absent or ambiguous") selected = matches[0] if selected not in candidates: raise ValueError( f"Drive '{selected.label}' does not target control '{control_device.label}'" ) m0 = chip.drive_matrix_elements(({}, {target_device: 1}), drives=[selected])[selected] m1 = chip.drive_matrix_elements( ({control_device: 1}, {control_device: 1, target_device: 1}), drives=[selected], )[selected] return CRSusceptibilityResult( m_control_0=m0, m_control_1=m1, IX_per_amplitude=(m0 + m1) / 2, ZX_per_amplitude=(m0 - m1) / 2, control=control_device.label, target=target_device.label, drive=selected.label, )
# --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- def _as_xyz(data: dict[str, np.ndarray] | np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Return ``(x, y, z)`` Bloch-component arrays from a dict or 2D array. Accepts either a mapping with keys ``"x"``, ``"y"``, ``"z"`` or a 2D ndarray shaped ``(3, N)`` (rows are X, Y, Z) or ``(N, 3)`` (columns are X, Y, Z). """ if isinstance(data, dict): try: return (np.asarray(data["x"], dtype=float), np.asarray(data["y"], dtype=float), np.asarray(data["z"], dtype=float)) except KeyError as exc: raise ValueError( f"Bloch dict must have keys 'x', 'y', 'z'; got {sorted(data)}" ) from exc arr = np.asarray(data, dtype=float) if arr.ndim == 2 and arr.shape[0] == 3: return arr[0], arr[1], arr[2] if arr.ndim == 2 and arr.shape[1] == 3: return arr[:, 0], arr[:, 1], arr[:, 2] raise ValueError( "Bloch data must be a dict with keys 'x', 'y', 'z' or a 2D array " f"shaped (3, N) or (N, 3); got array of shape {arr.shape}" )
[docs] def analyze_cross_resonance( durations: np.ndarray, ctrl0: dict[str, np.ndarray] | np.ndarray, ctrl1: dict[str, np.ndarray] | np.ndarray, sigma_ctrl0: dict[str, np.ndarray] | None = None, sigma_ctrl1: dict[str, np.ndarray] | None = None, t_offset: float = 0.0, ) -> CRHamiltonianResult: """Extract CR effective Hamiltonian coefficients from Bloch tomography data. Fits the target qubit's Bloch trajectory under a CR pulse to the analytic model in :func:`bloch_model`, once with the control qubit in |0⟩ and once in |1⟩. Combining the two fits isolates the six coefficients {IX, IY, IZ, ZX, ZY, ZZ}. The decomposition is:: p_ctrl0 = (I/2) part → ω_IX, ω_IY, ω_IZ p_ctrl1 = (Z/2) part → ω_ZX, ω_ZY, ω_ZZ Specifically: ω_ZQ = (p_ctrl0_Q - p_ctrl1_Q) / 2 ω_IQ = (p_ctrl0_Q + p_ctrl1_Q) / 2 for Q ∈ {X, Y, Z}. Parameters ---------- durations : (N,) ndarray CR pulse durations in **ns** (the package convention). They must be monotone but need not be equally spaced. The function converts them to seconds once, at its boundary, for the fit. ctrl0 : dict or 2D ndarray Target qubit's Bloch trajectory with the control in |0⟩. Give a dict with keys ``"x"``, ``"y"``, ``"z"`` or a 2D array with shape ``(3, N)`` or ``(N, 3)`` (see :func:`_as_xyz`). ctrl1 : dict or 2D ndarray Same, with the control in |1⟩. sigma_ctrl0 : dict with keys ``"x"``, ``"y"``, ``"z"`` (optional) Per-point standard deviations for the control-|0⟩ data, used as inverse weights in the least-squares fit. sigma_ctrl1 : dict with keys ``"x"``, ``"y"``, ``"z"`` (optional) Same for the control-|1⟩ data. t_offset : float Subtract this offset (in **ns**) from ``durations`` before the fit, e.g. to exclude a pulse-ramp transient. Returns ------- CRHamiltonianResult Six coefficients in **GHz** (the package's units contract). The function converts the Hz-valued fit internals exactly once, at this boundary. The uncertainties are local, residual-scaled covariance estimates, even when per-point standard deviations supply weights. An unavailable one-sigma uncertainty is ``None``. ``converged`` and ``message`` report the optimizer outcome separately from the fit quality and the local uncertainty, so you can still examine nonconverged candidates. Raises ------ RuntimeError If no initial guess gives a finite candidate for one of the control states. Examples -------- >>> import numpy as np >>> from quchip import analyze_cross_resonance >>> durations = np.linspace(0, 400, 20) # ns >>> ctrl0 = {"x": np.sin(0.02 * durations), "y": np.zeros_like(durations), ... "z": np.cos(0.02 * durations)} >>> ctrl1 = {"x": np.sin(0.03 * durations), "y": np.zeros_like(durations), ... "z": np.cos(0.03 * durations)} >>> result = analyze_cross_resonance(durations, ctrl0, ctrl1) >>> zx, zx_err = result.coeffs()["ZX"] # GHz """ # Boundary conversion: public durations/t_offset are in ns; the fit # heuristics (frequency guesses, td bounds) are Hz/seconds-valued, so # convert once here and leave every fit internal untouched. te = (np.asarray(durations, dtype=float) - t_offset) * 1e-9 x0, y0, z0 = _as_xyz(ctrl0) x1, y1, z1 = _as_xyz(ctrl1) s0 = sigma_ctrl0 or {} s1 = sigma_ctrl1 or {} fit0, cov0 = _fit_bloch(te, x0, y0, z0, s0.get("x"), s0.get("y"), s0.get("z")) fit1, cov1 = _fit_bloch(te, x1, y1, z1, s1.get("x"), s1.get("y"), s1.get("z")) p0, p1 = fit0.x, fit1.x hz_to_ghz = 1e-9 def _coeff(i): return (p0[i] - p1[i]) / 2, (p0[i] + p1[i]) / 2 def _err(i): if cov0 is None or cov1 is None: return None return np.sqrt(cov0[i, i] + cov1[i, i]) / 2 * hz_to_ghz ZX, IX = _coeff(0) ZY, IY = _coeff(1) ZZ, IZ = _coeff(2) return CRHamiltonianResult( IX=IX * hz_to_ghz, IY=IY * hz_to_ghz, IZ=IZ * hz_to_ghz, ZX=ZX * hz_to_ghz, ZY=ZY * hz_to_ghz, ZZ=ZZ * hz_to_ghz, IX_err=_err(0), IY_err=_err(1), IZ_err=_err(2), ZX_err=_err(0), ZY_err=_err(1), ZZ_err=_err(2), params_ctrl0=p0, params_ctrl1=p1, cov_ctrl0=cov0, cov_ctrl1=cov1, cost_ctrl0=fit0.cost, cost_ctrl1=fit1.cost, converged=bool(fit0.success and fit1.success), message=f"Control 0: {fit0.message}; control 1: {fit1.message}", )