"""Captured physical field statistics and inexpensive IQ receiver processing."""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
import numpy as np
from quchip.engine.field_statistics import block_diagonal, normal_spectrum, quadrature_transfer
from quchip.utils.constants import TWO_PI, hbar
from quchip.results.input_output import MeanFieldResponseResult
from quchip.utils.jax_utils import array_namespace, contains_tracer, is_jax_array, select_array_module
from quchip.results.receiver import IQReceiver, gaussian_samples, integrate_noise
from quchip.utils.labeling import resolve_label
def _capture(value: Any) -> Any:
if isinstance(value, np.ndarray):
value = value.copy()
value.setflags(write=False)
return value
def _index(ports: tuple[str, ...], output: Any) -> int:
label = resolve_label(output)
if label not in ports:
raise KeyError(f"Unknown output {label!r}; available: {list(ports)}")
return ports.index(label)
[docs]
@dataclass(frozen=True)
class VNAMeasurementSamples:
"""Synthetic Gaussian IQ draws with sample, sweep, then output axes.
Attributes
----------
ports : tuple of str
Output labels in the final array axis.
input : str
Driven input label.
axes : tuple
Named sweep-axis metadata.
incident : array_like
Incident complex field in ``1/sqrt(ns)``.
values : array_like
Sampled complex fields with leading shot and final output axes.
receiver : IQReceiver
Receiver used to integrate the physical spectra.
distribution : str
Sampling approximation.
"""
ports: tuple[str, ...]
input: str
axes: tuple[tuple[str, Any], ...]
incident: Any
values: Any
receiver: IQReceiver
distribution: str = "Gaussian field-moment approximation"
def __post_init__(self) -> None:
object.__setattr__(self, "values", _capture(self.values))
object.__setattr__(self, "incident", _capture(self.incident))
[docs]
def field(self, output: Any) -> Any:
"""Return sampled complex fields in ``1/sqrt(ns)``.
Parameters
----------
output : port object or str
Output plane to select.
"""
return self.values[..., _index(self.ports, output)]
[docs]
def ratio(self, output: Any) -> Any:
"""Return sampled output/input ratios, undefined for zero input.
Parameters
----------
output : port object or str
Output plane to select.
"""
xp = array_namespace(self.values)
zero = self.incident == 0
return xp.where(zero, xp.nan + 0j, self.field(output) / xp.where(zero, 1.0, self.incident))
[docs]
@dataclass(frozen=True)
class VNAMeasurementStatistics:
"""Integrated output means and full covariance of ``(I0,Q0,I1,Q1,...)``.
Attributes
----------
ports : tuple of str
Output labels in covariance order.
input : str
Driven input label.
axes : tuple
Named sweep-axis metadata.
incident : array_like
Incident complex field in ``1/sqrt(ns)``.
values : array_like
Integrated complex output means.
iq_covariance : array_like
Full real IQ covariance with two axes per output.
receiver : IQReceiver
Receiver used for integration.
contributions : mapping
Named integrated covariance contributions.
"""
ports: tuple[str, ...]
input: str
axes: tuple[tuple[str, Any], ...]
incident: Any
values: Any
iq_covariance: Any
receiver: IQReceiver
contributions: Mapping[str, Any]
def __post_init__(self) -> None:
for name in ("incident", "values", "iq_covariance"):
object.__setattr__(self, name, _capture(getattr(self, name)))
object.__setattr__(self, "contributions", MappingProxyType(
{key: _capture(value) for key, value in self.contributions.items()}))
[docs]
def mean(self, output: Any) -> Any:
"""Return the integrated complex mean at an output.
Parameters
----------
output : port object or str
Output plane to select.
"""
return self.values[..., _index(self.ports, output)]
[docs]
def covariance(self, output: Any, other: Any = None) -> Any:
"""Return a 2x2 IQ covariance or cross-output covariance block.
Parameters
----------
output : port object or str
First output plane.
other : port object, str, or None, optional
Second output plane. ``None`` selects ``output``.
"""
i = 2 * _index(self.ports, output)
j = i if other is None else 2 * _index(self.ports, other)
return self.iq_covariance[..., i:i+2, j:j+2]
[docs]
def noise_contributions(self, output: Any) -> Mapping[str, Any]:
"""Return integrated source covariance blocks, with detector vacuum.
Parameters
----------
output : port object or str
Output plane to select.
"""
i = 2 * _index(self.ports, output)
return MappingProxyType({key: value[..., i:i+2, i:i+2] for key, value in self.contributions.items()})
[docs]
def sample(self, count: int, *, seed: int | None = None, key: Any = None) -> VNAMeasurementSamples:
"""Draw joint Gaussian IQ samples without a physical solver run.
Use seed for NumPy draws or an explicit JAX random key. The covariance
includes anomalous/cross-output second moments but does not specify
higher-order non-Gaussian photon statistics.
Parameters
----------
count : int
Positive number of samples.
seed : int or None, optional
NumPy random seed.
key : jax.Array or None, optional
JAX random key. Do not use it together with ``seed``.
"""
xp = array_namespace(self.values)
mean = xp.stack((xp.real(self.values), xp.imag(self.values)), axis=-1)
draws = gaussian_samples(mean.reshape((*self.values.shape[:-1], -1)), self.iq_covariance,
count, seed=seed, key=key)
values = draws[..., 0::2] + 1j * draws[..., 1::2]
return VNAMeasurementSamples(self.ports, self.input, self.axes, self.incident, values, self.receiver)
[docs]
def calibrate(self, factors: Mapping[Any, Any]) -> VNAMeasurementStatistics:
"""Multiply output fields and covariance axes by calibration factors.
Parameters
----------
factors : mapping
Complex amplitude factor keyed by output plane. Outputs that are not in the mapping use one.
"""
xp = select_array_module(is_jax_array(self.values) or contains_tracer(tuple(factors.values())))
normalized = {resolve_label(port): value for port, value in factors.items()}
for port in normalized:
_index(self.ports, port)
gains = xp.asarray([normalized.get(port, 1.0) for port in self.ports])
blocks = quadrature_transfer(gains, gains, xp)
transform = block_diagonal(blocks, xp)
transform = xp.real(transform)
covariance = transform @ self.iq_covariance @ transform.T
contributions = {name: transform @ value @ transform.T for name, value in self.contributions.items()}
return VNAMeasurementStatistics(self.ports, self.input, self.axes, self.incident,
self.values * gains, covariance, self.receiver, contributions)
[docs]
@dataclass(frozen=True)
class VNAMeasurement(MeanFieldResponseResult):
"""Physical means and spectra captured before you choose a receiver.
noise_components maps physical source labels to (white covariance, excess
spectral covariance). The excess spectral covariance has an
offset-frequency axis before its two IQ axes. Device-generated excess
includes input-system correlations and can be negative. It is not an
independent random source.
Attributes
----------
ports : tuple of str
Captured output labels.
input : str
Driven input label.
values : array_like
Complex output means with the output axis last.
incident : array_like
Incident complex field in ``1/sqrt(ns)``.
frequencies, amplitudes : array_like
Probe frequencies in GHz and incident amplitudes.
shape : tuple of int
Broadcast sweep shape.
axes : tuple
Named sweep-axis metadata.
diagnostics : mapping
Captured solve diagnostics.
noise_frequencies : array_like
Two-sided offsets in GHz.
noise_components : mapping
Named white and excess IQ covariance spectra.
output_delays : array_like
Output reference-plane delays in ns.
parameters : tuple of mapping
Bound parameters for each sweep point.
modes : tuple of str
Captured internal Fock-mode labels.
mode_amplitudes, photon_numbers : array_like
Internal stationary means and occupations.
mode_frequencies : array_like
Internal stationary-frame frequencies in GHz.
conventions : tuple of str
Units and statistical conventions.
"""
noise_frequencies: Any
noise_components: Mapping[str, tuple[Any, Any]]
output_delays: Any
parameters: tuple[Mapping[str, Any], ...]
modes: tuple[str, ...]
mode_amplitudes: Any
photon_numbers: Any
mode_frequencies: Any
conventions: tuple[str, ...] = (
"b=I+jQ, engineering e^{+jωt}, j=i. Frequencies in GHz, time in ns.",
"Physical normal-order spectra; detector vacuum added by the receiver",
"Independent stationary experiments across sweep points",
"Gaussian sampling uses second moments, not full photon statistics",
)
def __post_init__(self) -> None:
super().__post_init__()
for name in ("values", "incident", "noise_frequencies", "frequencies", "amplitudes", "output_delays",
"mode_amplitudes", "photon_numbers", "mode_frequencies"):
object.__setattr__(self, name, _capture(getattr(self, name)))
object.__setattr__(self, "axes", tuple((name, _capture(value)) for name, value in self.axes))
object.__setattr__(self, "parameters", tuple(MappingProxyType(
{name: _capture(value) for name, value in point.items()}) for point in self.parameters))
object.__setattr__(self, "noise_components", MappingProxyType({
name: (_capture(white), _capture(excess)) for name, (white, excess) in self.noise_components.items()}))
def _mode_index(self, mode: Any) -> int:
label = resolve_label(mode)
if label not in self.modes:
raise KeyError(f"Unknown Fock mode {label!r}; available: {list(self.modes)}")
return self.modes.index(label)
[docs]
def mode_amplitude(self, mode: Any) -> Any:
"""Return captured <a> in the mode's stationary frame, in sqrt(photons).
Use ``mode_frequency(mode)`` for that frame's frequency in GHz. The
value is the internal field with the full declared wiring included. The
returned array follows the measurement's sweep axes.
Parameters
----------
mode : device object or str
Captured Fock mode.
"""
return self.mode_amplitudes[..., self._mode_index(mode)]
[docs]
def photon_number(self, mode: Any) -> Any:
"""Return captured <a†a>, with coherent and incoherent occupation.
Available for authored Fock modes and nonlinear modes. The general
solver keeps the declared basis projection and truncation. Receiver
integration and calibration do not change this occupation.
Parameters
----------
mode : device object or str
Captured Fock mode.
"""
return self.photon_numbers[..., self._mode_index(mode)]
[docs]
def mode_frequency(self, mode: Any) -> Any:
"""Return a mode's captured stationary frame frequency in GHz.
Parameters
----------
mode : device object or str
Captured Fock mode.
"""
return self.mode_frequencies[..., self._mode_index(mode)]
[docs]
def noise_contributions(self, output: Any) -> Mapping[str, Any]:
"""Return physical IQ spectral contributions at the captured offsets.
Parameters
----------
output : port object or str
Output plane to select.
"""
i = 2 * _index(self.ports, output)
return MappingProxyType({name: white[..., None, i:i+2, i:i+2] + excess[..., i:i+2, i:i+2]
for name, (white, excess) in self.noise_components.items()})
[docs]
def noise_spectrum(self, output: Any, *, unit: str = "quanta") -> Any:
"""Return physical output noise in quanta, W/Hz, or dBm/Hz.
The final axis is ``noise_frequencies`` relative to each probe carrier.
This normally ordered spectrum does not include coherent signal and
receiver vacuum. Power units use hf times occupation at the absolute
sideband frequency and require positive physical frequencies. No solve
is run.
Parameters
----------
output : port object or str
Output plane to select.
unit : {"quanta", "W/Hz", "dBm/Hz"}, default="quanta"
Spectral-density unit.
"""
xp = array_namespace(self.values)
spectrum = normal_spectrum(sum(self.noise_contributions(output).values()), xp)
if unit == "quanta":
return spectrum
if unit not in {"W/Hz", "dBm/Hz"}:
raise ValueError("unit must be 'quanta', 'W/Hz', or 'dBm/Hz'.")
frequency = xp.broadcast_to(xp.asarray(self.frequencies), self.shape)[..., None] + self.noise_frequencies
if not contains_tracer(frequency) and np.any(np.asarray(frequency) <= 0):
raise ValueError("Power noise spectra require positive absolute sideband frequencies.")
power = TWO_PI*hbar*frequency*1e9*spectrum
if unit == "W/Hz":
return power
return xp.where(power > 0, 10*xp.log10(xp.where(power > 0, power, 1.0)/1e-3), -xp.inf)
[docs]
def statistics(self, *, receiver: IQReceiver) -> VNAMeasurementStatistics:
"""Integrate captured spectra and detector vacuum without a solver call.
Parameters
----------
receiver : IQReceiver
Boxcar receiver and optional digital transfer.
"""
covariance, contributions, mean_gain = integrate_noise(
self.values, self.noise_frequencies, self.noise_components, self.output_delays, receiver)
return VNAMeasurementStatistics(self.ports, self.input, self.axes, self.incident,
self.values * mean_gain, covariance, receiver, contributions)
[docs]
def sample(
self, count: int, *, receiver: IQReceiver, seed: int | None = None, key: Any = None,
) -> VNAMeasurementSamples:
"""Integrate for a receiver and draw samples from the captured moments.
Parameters
----------
count : int
Positive number of samples.
receiver : IQReceiver
Boxcar receiver and optional digital transfer.
seed : int or None, optional
NumPy random seed.
key : jax.Array or None, optional
JAX random key. Do not use it together with ``seed``.
"""
return self.statistics(receiver=receiver).sample(count, seed=seed, key=key)