Source code for quchip.results.terminal

"""Projective measurements of saved states and calibrated detector responses."""
from __future__ import annotations

from dataclasses import dataclass
from itertools import product
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Mapping

import numpy as np

from quchip.results.receiver import gaussian_samples, validate_samples
from quchip.utils.jax_utils import contains_tracer, select_array_module
from quchip.utils.labeling import resolve_label
from quchip.utils.values import copy_value

if TYPE_CHECKING:
    from quchip.engine.reference import FieldChannel


def _namespace(*values: Any) -> Any:
    from jax.tree_util import tree_leaves
    from quchip.utils.jax_utils import is_jax_array
    return select_array_module(contains_tracer(values) or any(is_jax_array(v) for v in tree_leaves(values)))


def _probabilities(values: Any) -> Any:
    xp = _namespace(values)
    values = xp.real(xp.asarray(values))
    if not contains_tracer(values):
        if (not np.all(np.isfinite(values)) or np.any(values < -1e-7)
                or not np.allclose(np.sum(values, axis=-1), 1, atol=1e-6)):
            raise ValueError("Measurement requires normalized, nonnegative Born probabilities.")
    values = xp.maximum(values, 0)
    return values / xp.sum(values, axis=-1, keepdims=True)


[docs] @dataclass(frozen=True) class IQReadout: """Calibrate one complex IQ distribution per ordered physical outcome. means has shape (outcomes,). iq_covariance is (2,2) or (outcomes,2,2) in the same signal units squared. They are complete conditional detector distributions, so do not add apparatus noise that the calibration already includes. Covariances must be positive definite. Wiring-derived readouts use fields in 1/sqrt(ns) and keep their integrated noise contributions. Means and IQ covariances follow the VNA engineering convention. Attributes ---------- means : array_like Complex conditional means, one per physical outcome. iq_covariance : array_like Covariance with shape ``(outcomes, 2, 2)``. contributions : mapping or None Optional named integrated noise contributions. """ means: Any iq_covariance: Any contributions: Mapping[str, Any] | None = None def __post_init__(self) -> None: xp = _namespace(self.means, self.iq_covariance) means = xp.asarray(self.means, dtype=complex) covariance = xp.asarray(self.iq_covariance, dtype=float) if means.ndim != 1 or len(means) < 1: raise ValueError("Readout means must be a nonempty vector ordered by measurement outcomes.") if covariance.shape not in ((2, 2), (len(means), 2, 2)): raise ValueError("iq_covariance must have shape (2,2) or (outcomes,2,2).") if not contains_tracer((means, covariance)): if not np.all(np.isfinite(means)) or not np.all(np.isfinite(covariance)): raise ValueError("Readout calibration must be finite.") if (not np.allclose(covariance, np.swapaxes(covariance, -1, -2), atol=1e-12) or np.any(np.linalg.eigvalsh(covariance) <= 0)): raise ValueError("Readout IQ covariance must be symmetric positive definite.") object.__setattr__(self, "means", copy_value(means, readonly=True)) object.__setattr__(self, "iq_covariance", copy_value( xp.broadcast_to(covariance, (len(means), 2, 2)), readonly=True)) object.__setattr__(self, "contributions", MappingProxyType(copy_value( dict(self.contributions or {}), readonly=True)))
[docs] @classmethod def from_wiring(cls, chip: Any, output: Any, *, means: Any, frequency: Any, receiver: Any, noise_frequencies: Any = None) -> IQReadout: """Build an IQ detector from wiring and conditional coherent fields. Use the same boundary-field units and vacuum assumptions as SimulationResult.iq_readout(). This method captures the chip's current wiring, whereas the result method uses the wiring that the simulation kept. Parameters ---------- chip : Chip Chip whose current output wiring is captured. output : port object or str Output reference plane. means : array_like or mapping Engineering-convention conditional Markov-boundary fields in ``1/sqrt(ns)``. A mapping gives multiple boundary channels. frequency : scalar Carrier frequency in GHz. receiver : IQReceiver Boxcar receiver and optional digital transfer. noise_frequencies : array_like or None, optional Two-sided offsets in GHz. ``None`` uses the standard grid. """ wiring = ReadoutWiring.capture(chip.resolve().slh, chip.backend.array_module) return wiring.iq_readout(output, means=means, frequency=frequency, receiver=receiver, noise_frequencies=noise_frequencies)
def _weights(self, probabilities: Any) -> Any: weights = _probabilities(probabilities) if weights.shape[-1] != len(self.means): raise ValueError("Readout calibration must contain one distribution per physical outcome.") return weights
[docs] def mean(self, probabilities: Any) -> Any: """Return the mean of the conditional IQ mixture. Parameters ---------- probabilities : array_like Physical-outcome probabilities on the final axis. """ return self._weights(probabilities) @ self.means
[docs] def covariance(self, probabilities: Any) -> Any: """Return the mixture covariance, with conditional-mean separation. Parameters ---------- probabilities : array_like Physical-outcome probabilities on the final axis. """ weights = self._weights(probabilities) xp = _namespace(weights, self.means) centers = xp.stack((xp.real(self.means), xp.imag(self.means)), axis=-1) centered = centers - (weights @ centers)[..., None, :] return xp.sum(weights[..., :, None, None] * ( self.iq_covariance + centered[..., :, :, None] * centered[..., :, None, :]), axis=-3)
[docs] @dataclass(frozen=True) class ReadoutWiring: """Capture only field references and downstream mixing, without quantum operators.""" fields: tuple[FieldChannel, ...] exposed: tuple[str, ...] graph: Any array_module: Any
[docs] @classmethod def capture(cls, slh: Any, array_module: Any) -> ReadoutWiring: """Keep the solved wiring independently of later component edits.""" from quchip.engine.reference import FieldChannel return cls(tuple(FieldChannel(c.key, c.reference, c.input_occupation) for c in slh.channels), tuple(c.key for c in slh.external_channels), slh.output_network, array_module)
[docs] def iq_readout(self, output: Any, *, means: Any, frequency: Any, receiver: Any, noise_frequencies: Any = None) -> Any: """Propagate conditional coherent boundary templates and downstream noise.""" from quchip.engine.field_statistics import propagate_noise from quchip.engine.output_network import pad_mixing from quchip.engine.reference import cw_transfer from quchip.results.receiver import _engineering_iq, integrate_noise, noise_grid from quchip.utils.jax_utils import is_jax_array, is_jax_namespace label = resolve_label(output) if label not in self.exposed: raise ValueError(f"Unknown exposed output {label!r}; available: {self.exposed}.") templates = {resolve_label(k): v for k, v in means.items()} if isinstance(means, Mapping) else {label: means} if not templates or any(k not in self.exposed for k in templates): raise ValueError("Conditional fields must name captured exposed boundary channels.") xp = select_array_module(is_jax_namespace(self.array_module) or any(is_jax_array(v) for v in templates.values()) or contains_tracer((frequency, receiver.integration_time, tuple(templates.values())))) if not contains_tracer(frequency) and (np.ndim(frequency) or not np.isfinite(frequency)): raise ValueError("Readout frequency must be a finite scalar in GHz.") arrays = {k: xp.conj(xp.asarray(v, dtype=complex)) for k, v in templates.items()} first = next(iter(arrays.values())) if first.ndim != 1 or len(first) < 1 or any(a.shape != first.shape for a in arrays.values()): raise ValueError("Conditional fields must be equal-length vectors ordered by physical outcome.") selected = next(i for i, c in enumerate(self.fields) if c.key == label) fields = xp.stack([arrays.get(c.key, xp.zeros_like(first)) for c in self.fields], axis=-1) mixing = xp.eye(len(self.fields)) if self.graph is None else pad_mixing( self.graph.evaluate(frequency, xp)[0], len(self.fields), xp) gain = cw_transfer(self.fields[selected].reference.outbound, frequency, xp) centers = xp.conj((fields @ mixing.T)[..., selected] * gain) offsets = xp.asarray(noise_grid(noise_frequencies)) size = 2 * (len(self.fields) if self.graph is not None else 1) components, delays = propagate_noise( self.fields, xp.eye(len(self.fields)), self.graph, xp.zeros((len(offsets), size, size), dtype=complex), (label,), frequency, offsets, xp, include_inputs=False) components = {name: (_engineering_iq(white, xp), _engineering_iq(excess, xp)) for name, (white, excess) in components.items()} covariance, budget, dc_gain = integrate_noise(xp.zeros(1), offsets, components, delays, receiver) return IQReadout(centers * dc_gain, covariance, budget)
[docs] @dataclass(frozen=True) class StateSamples: """Independent measurement shots, with indices that select the ordered outcome tuples. physical_indices are Born draws. indices also includes assignment errors, if requested. iq is present only for a conditional IQ readout. Sweep axes follow the leading shot axis. No conditional state is returned. Attributes ---------- devices : tuple of str Measured device labels. outcomes : tuple of tuple of int Ordered outcome tuples. axes : tuple Sweep-axis metadata. physical_indices, indices : array_like Born and recorded outcome indices. iq : array_like or None Conditional IQ draws, when requested. """ devices: tuple[str, ...] outcomes: tuple[tuple[int, ...], ...] axes: tuple[tuple[str, Any], ...] physical_indices: Any indices: Any iq: Any = None def __post_init__(self) -> None: for name in ("axes", "physical_indices", "indices", "iq"): object.__setattr__(self, name, copy_value(getattr(self, name), readonly=True))
[docs] def counts(self) -> dict[tuple[int, ...], Any]: """Count recorded outcomes along the shot axis, and keep sweep axes.""" xp = _namespace(self.indices) return {outcome: xp.sum(self.indices == i, axis=0) for i, outcome in enumerate(self.outcomes)}
[docs] @dataclass(frozen=True) class StateMeasurement: """Born probabilities in a captured local product measurement basis. Attributes ---------- devices : tuple of str Measured device labels. outcomes : tuple of tuple of int Ordered product-basis outcomes. probabilities : array_like Normalized probabilities with outcomes on the final axis. axes : tuple Sweep-axis metadata. """ devices: tuple[str, ...] outcomes: tuple[tuple[int, ...], ...] probabilities: Any axes: tuple[tuple[str, Any], ...] = () def __post_init__(self) -> None: probabilities = _probabilities(self.probabilities) if probabilities.shape[-1] != len(self.outcomes): raise ValueError("Probabilities and outcome labels must have the same length.") object.__setattr__(self, "probabilities", copy_value(probabilities, readonly=True)) object.__setattr__(self, "axes", copy_value(self.axes, readonly=True)) def _assignment(self, assignment: Any) -> Any: xp = _namespace(self.probabilities, assignment) matrix = xp.asarray(assignment, dtype=float) size = len(self.outcomes) if matrix.shape != (size, size): raise ValueError("Assignment must have shape (recorded outcomes, physical outcomes).") if not contains_tracer(matrix): if (not np.all(np.isfinite(matrix)) or np.any(matrix < 0) or not np.allclose(np.sum(matrix, axis=0), 1, atol=1e-8)): raise ValueError("Assignment columns must be nonnegative probabilities summing to one.") return matrix
[docs] def recorded_probabilities(self, assignment: Any) -> Any: """Apply ``A[recorded, physical]`` to the Born probabilities. Parameters ---------- assignment : array_like Column-stochastic ``(recorded, physical)`` assignment matrix. """ return self.probabilities @ self._assignment(assignment).T
[docs] def sample(self, count: int, *, seed: int | None = None, key: Any = None, assignment: Any = None, readout: IQReadout | None = None) -> StateSamples: """Draw Born outcomes, and optionally record assignment errors or conditional IQ. Assignment and IQ are separate detector models. IQ samples stay unclassified, and quchip does not infer a threshold or assignment matrix from them. Discrete labels are not differentiable. Use an explicit key under JAX. Parameters ---------- count : int Positive number of independent shots. seed : int or None, optional NumPy random seed. key : jax.Array or None, optional JAX random key. Do not use with ``seed``. assignment : array_like or None, optional Column-stochastic ``(recorded, physical)`` assignment matrix. readout : IQReadout or None, optional Conditional IQ calibration for each physical outcome. """ if assignment is not None and readout is not None: raise ValueError("Choose assignment or IQ readout; IQ samples are not automatically classified.") xp = validate_samples(count, seed, key, (self.probabilities, assignment, None if readout is None else (readout.means, readout.iq_covariance))) shape = (count, *self.probabilities.shape[:-1]) if key is None: rng = np.random.default_rng(seed) uniforms = xp.asarray(rng.uniform(size=(2, *shape))) noise_key = None noise_seed = int(rng.integers(0, 2**32)) else: import jax.random choice_key, noise_key = jax.random.split(key) uniforms = jax.random.uniform(choice_key, (2, *shape)) noise_seed = None physical = xp.sum(uniforms[0, ..., None] >= xp.cumsum(xp.asarray(self.probabilities), axis=-1), axis=-1) physical = xp.minimum(physical, len(self.outcomes) - 1) recorded, iq = physical, None if assignment is not None: conditional = xp.asarray(self._assignment(assignment)).T[physical] recorded = xp.sum(uniforms[1, ..., None] >= xp.cumsum(conditional, axis=-1), axis=-1) recorded = xp.minimum(recorded, len(self.outcomes) - 1) if readout is not None: readout._weights(self.probabilities) means = xp.asarray(readout.means)[physical] centers = xp.stack((xp.real(means), xp.imag(means)), axis=-1) draws = gaussian_samples(centers, xp.asarray(readout.iq_covariance)[physical], 1, seed=noise_seed, key=noise_key)[0] iq = draws[..., 0] + 1j * draws[..., 1] return StateSamples(self.devices, self.outcomes, self.axes, physical, recorded, iq)
[docs] def measure_result(result: Any, devices: tuple[Any, ...], *, t: Any = None, basis: Any = "energy") -> StateMeasurement: """Project kept states with captured basis maps and existing result coordinates.""" from quchip.results.results import SimulationBatchResult from quchip.results.partitioned import PartitionedSimulationResult labels = tuple(resolve_label(device) for device in devices) if not labels or len(set(labels)) != len(labels): raise ValueError("Measure one or more distinct devices, in the requested outcome order.") if isinstance(basis, Mapping): basis = {resolve_label(label): value for label, value in basis.items()} unknown = set(basis) - set(labels) if unknown: raise ValueError(f"Measurement basis names unmeasured devices: {sorted(unknown)}.") elif not isinstance(basis, str) and len(labels) != 1: raise ValueError("Joint custom bases require a device-to-unitary mapping.") if isinstance(result, SimulationBatchResult): points = [measure_result(point, devices, t=t, basis=basis) for point in result] if not points: raise ValueError("Cannot measure an empty batch.") if any(point.outcomes != points[0].outcomes for point in points): raise ValueError("Batch measurements require matching outcome dimensions.") xp = _namespace(*(point.probabilities for point in points)) probabilities = xp.stack([point.probabilities for point in points]) return StateMeasurement(labels, points[0].outcomes, probabilities.reshape((*result.shape, len(points[0].outcomes))), result.axes) if isinstance(result, PartitionedSimulationResult): groups: dict[int, list[str]] = {} for label in labels: groups.setdefault(result.partition.owner_of(label), []).append(label) points = [measure_result(result.components[index], tuple(group), t=t, basis={k: v for k, v in basis.items() if k in group} if isinstance(basis, Mapping) else basis) for index, group in groups.items()] if len(points) == 1: return points[0] xp = _namespace(*(point.probabilities for point in points)) dimensions = {label: max(outcome[i] for outcome in point.outcomes)+1 for point in points for i, label in enumerate(point.devices)} outcomes = tuple(product(*(range(dimensions[label]) for label in labels))) probabilities = xp.ones(len(outcomes)) for point in points: digits = np.asarray(outcomes)[:, [labels.index(label) for label in point.devices]].T indices = np.ravel_multi_index(tuple(digits), tuple(dimensions[label] for label in point.devices)) probabilities = probabilities * point.probabilities[xp.asarray(indices)] return StateMeasurement(labels, outcomes, probabilities) backend = result._backend selected = [result._resolve_device_idx(label)[0] for label in labels] space = getattr(result, "_excitation_space", None) if space is None: state = result.state(t) dims = tuple(result.dims) else: from math import prod from quchip.utils.memory import dense_bytes, require_memory dims = tuple(result.dims[axis] for axis in selected) require_memory(dense_bytes(prod(dims), prod(dims), copies=4), task="Joint measurement coordinates", remedy="Measure fewer devices at a time.") raw = result._raw_final_state() if t is None else result._raw_state_at(t) state = space.reduced(raw, backend, selected) selected = list(range(len(labels))) array = backend.to_array(state) xp = _namespace(array, basis) ket = backend.is_ket(state) tensor = xp.asarray(array).reshape(dims if ket else dims + dims) if isinstance(basis, str): if basis not in ("energy", "solver"): raise ValueError("basis must be 'energy', 'solver', or local unitary columns in the energy basis.") custom: Mapping[str, Any] = {} else: custom = basis if isinstance(basis, Mapping) else {labels[0]: basis} for label, axis in zip(labels, selected): size = dims[axis] rotation = xp.asarray(custom.get(label, xp.eye(size)), dtype=complex) if rotation.shape != (size, size): raise ValueError(f"Measurement basis for {label!r} must have shape {(size, size)}.") if not contains_tracer(rotation) and not np.allclose(rotation.conj().T @ rotation, np.eye(size), atol=1e-8): raise ValueError("Measurement basis columns must be orthonormal.") if not isinstance(basis, str) or basis == "energy": captured = result._bases.get(label) if captured is None: raise ValueError("Energy measurement requires captured basis information; use basis='solver'.") energy = captured.energy_to_solver() if energy is not None: rotation = xp.asarray(energy) @ rotation tensor = xp.moveaxis(xp.tensordot(rotation.conj().T, tensor, axes=(1, axis)), 0, axis) if not ket: tensor = xp.moveaxis(xp.tensordot(rotation.T, tensor, axes=(1, axis+len(dims))), 0, axis+len(dims)) diagonal = xp.abs(tensor)**2 if ket else xp.real(xp.diag(tensor.reshape((int(np.prod(dims)),)*2))).reshape(dims) remaining = [i for i in range(len(dims)) if i not in selected] probabilities = xp.transpose(diagonal, selected + remaining).reshape( (int(np.prod([dims[i] for i in selected])), -1)).sum(axis=-1) outcomes = tuple(product(*(range(dims[i]) for i in selected))) return StateMeasurement(labels, outcomes, probabilities)