Source code for quchip.results.receiver

"""Shared heterodyne receiver integration and random sampling."""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable

import numpy as np

from quchip.engine.field_statistics import quadrature_transfer
from quchip.utils.jax_utils import contains_tracer, is_jax_array, select_array_module


[docs] @dataclass(frozen=True) class IQReceiver: """Configure ideal heterodyne detection after a physical measurement. Parameters ---------- integration_time : scalar Positive boxcar duration in ns. White detector and field noise scale as ``1 / integration_time``. transfer : callable or None, optional Digital complex amplitude transfer function of offset frequency in GHz, which must be supported on the captured spectral grid. Its value at zero also scales the mean field. It does not change the simulated chip or its physical noise sources. tolerance : float, default=0.02 Relative convergence and spectral-support tolerance, with ``0 < tolerance < 1``. Notes ----- The receiver integrates the captured two-sided normally ordered spectrum with the sinc-squared boxcar response and adds detector vacuum with covariance ``1 / (2 * integration_time)`` per IQ quadrature. See Caves, *Phys. Rev. D* 26, 1817 (1982), doi:10.1103/PhysRevD.26.1817, for the phase-preserving amplifier noise convention. """ integration_time: Any transfer: Callable[[Any], Any] | None = field(default=None, repr=False) tolerance: float = 0.02 def __post_init__(self) -> None: if not contains_tracer(self.integration_time): value = np.asarray(self.integration_time) if value.ndim or not np.isfinite(value) or value <= 0: raise ValueError("integration_time must be a finite positive scalar in ns.") if self.transfer is not None and not callable(self.transfer): raise TypeError("Receiver transfer must be callable.") if not np.isfinite(self.tolerance) or not 0 < self.tolerance < 1: raise ValueError("Receiver tolerance must lie between zero and one.")
[docs] def validate_samples(count: int, seed: Any, key: Any, values: Any) -> Any: """Validate Gaussian-shot arguments and choose NumPy or JAX arithmetic. Parameters ---------- count : int Positive number of draws. seed : int or None NumPy generator seed. Do not pass together with ``key``. key : jax.Array or None Explicit JAX PRNG key, required to sample traced values. values : object Mean and covariance values used to infer the array namespace. Returns ------- module NumPy-like namespace selected for the draw. """ if not isinstance(count, int) or isinstance(count, bool) or count < 1: raise ValueError("Sample count must be a positive integer.") if seed is not None and key is not None: raise ValueError("Specify seed or key, not both.") if key is None and contains_tracer(values): raise ValueError("Sampling traced statistics requires an explicit JAX random key.") if key is not None: import jax.numpy as jnp return jnp from jax.tree_util import tree_leaves return select_array_module(any(is_jax_array(v) for v in tree_leaves(values)))
[docs] def gaussian_samples(mean: Any, covariance: Any, count: int, *, seed: Any = None, key: Any = None) -> Any: """Draw correlated real Gaussian vectors. Parameters ---------- mean : array_like Mean with shape ``(..., n)``. covariance : array_like Positive-definite covariance with shape ``(..., n, n)``. count : int Number of draws. seed, key : optional Use ``seed`` for NumPy or ``key`` for JAX. Do not pass both. Returns ------- array Samples with shape ``(count, ..., n)`` in the input array namespace. """ xp = validate_samples(count, seed, key, (mean, covariance)) mean, covariance = xp.asarray(mean), xp.asarray(covariance) shape = (count, *mean.shape) if key is None: standard = xp.asarray(np.random.default_rng(seed).normal(size=shape)) else: import jax.random standard = jax.random.normal(key, shape, dtype=covariance.dtype) root = xp.linalg.cholesky(covariance) return mean + xp.einsum("...ij,n...j->n...i", root, standard)
[docs] def noise_grid(frequencies: Any = None) -> np.ndarray: """Validate or construct the two-sided spectral offset grid in GHz. Parameters ---------- frequencies : 1-D array_like or None, optional Strictly increasing, finite, symmetric offsets that contain zero. With ``None``, return the default grid spanning ``-0.1`` to ``+0.1`` GHz. Returns ------- numpy.ndarray Validated offsets in GHz. """ if frequencies is None: positive = np.geomspace(1e-9, 0.1, 161) return np.concatenate((-positive[::-1], [0.0], positive)) values = np.asarray(frequencies, dtype=float) if (values.ndim != 1 or len(values) < 5 or not np.all(np.isfinite(values)) or np.any(np.diff(values) <= 0) or not np.any(values == 0) or not np.allclose(values, -values[::-1], atol=0, rtol=1e-12)): raise ValueError("noise_frequencies must be finite, increasing, symmetric offsets including zero (GHz).") return values
def _engineering_iq(spectrum: Any, xp: Any) -> Any: """Reverse both Q axes and conjugate spectra, preserving physical sideband labels.""" signs = xp.where(xp.arange(spectrum.shape[-1]) % 2, -1, 1) return signs[:, None] * xp.conj(spectrum) * signs[None, :]
[docs] def integrate_noise(values: Any, noise_frequencies: Any, noise_components: Any, output_delays: Any, receiver: IQReceiver) -> tuple[Any, Any, Any]: """Integrate normal spectra and detector vacuum, and return covariance, budget, and DC gain. Captured IQ spectra and the receiver transfer use the engineering convention. """ count = values.shape[-1] receiver_upper = None if receiver.transfer is None else receiver.transfer(noise_frequencies) xp = select_array_module(is_jax_array(values) or contains_tracer((receiver.integration_time, receiver_upper))) trapezoid = getattr(xp, "trapezoid", None) or xp.trapz frequencies = xp.asarray(noise_frequencies) delays = xp.repeat(output_delays, 2, axis=-1) overlap = xp.maximum(1 - xp.abs(delays[..., :, None] - delays[..., None, :]) / receiver.integration_time, 0) window = xp.sinc(frequencies * receiver.integration_time) ** 2 mean_gain = 1.0 transform = None if receiver.transfer is not None: upper = xp.asarray(receiver_upper) + xp.zeros_like(frequencies) lower = xp.asarray(receiver.transfer(-frequencies)) + xp.zeros_like(frequencies) mean_gain = receiver.transfer(xp.asarray(0.0)) local = quadrature_transfer(upper, lower, xp) transform = xp.kron(xp.eye(count), local) components = dict(noise_components) components["receiver.vacuum"] = (xp.broadcast_to(xp.eye(2 * count) / 2, (*values.shape[:-1], 2 * count, 2 * count)), xp.zeros((*values.shape[:-1], len(frequencies), 2 * count, 2 * count))) contributions, coarse, edge_bounds = {}, [], [] for name, (white, excess) in components.items(): white, excess = xp.asarray(white), xp.asarray(excess) if transform is None: spectrum = excess analytic = white * overlap / receiver.integration_time else: relative = delays[..., :, None] - delays[..., None, :] phase = xp.exp(-2j * xp.pi * frequencies[:, None, None] * relative[..., None, :, :]) physical = excess + white[..., None, :, :] * phase spectrum = transform @ physical @ xp.conj(xp.swapaxes(transform, -1, -2)) analytic = 0.0 edge_bounds.append(xp.max(xp.abs(spectrum[..., xp.asarray([0, -1]), :, :])) / (xp.pi**2 * frequencies[-1] * receiver.integration_time**2)) weighted = xp.real(spectrum) * window[:, None, None] contributions[name] = analytic + trapezoid(weighted, x=frequencies, axis=-3) coarse.append(analytic + trapezoid(weighted[..., ::2, :, :], x=frequencies[::2], axis=-3)) covariance = sum(contributions.values()) covariance = (covariance + xp.swapaxes(covariance, -1, -2)) / 2 if not contains_tracer((covariance, receiver.integration_time)): scale = max(float(np.max(np.abs(covariance))), np.finfo(float).tiny) error = float(np.max(np.abs(np.asarray(covariance - sum(coarse))))) / scale if error > receiver.tolerance: raise ValueError("Receiver integration is unresolved on the captured noise_frequencies; " "capture a finer spectral grid.") if sum(float(value) for value in edge_bounds) / scale > receiver.tolerance: raise ValueError("Receiver integration extends beyond captured spectral support; " "capture a wider noise_frequencies grid.") if receiver.transfer is not None: edge = np.max(np.abs(np.asarray(receiver.transfer(frequencies[xp.asarray([0, -1])])))) if edge > receiver.tolerance: raise ValueError("Receiver filter extends beyond captured spectral support; " "capture a wider noise_frequencies grid.") if np.min(np.linalg.eigvalsh(np.asarray(covariance))) <= 0: raise ValueError("Integrated IQ covariance is not positive; " "check spectral resolution and model validity.") return covariance, contributions, mean_gain