"""Results for continuous-wave port scattering calculations."""
from __future__ import annotations
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Mapping
import numpy as np
from quchip.utils.jax_utils import concrete_array_module, contains_tracer, is_jax_array, select_array_module
from quchip.utils.labeling import resolve_label
class _LazyDiagnostics(Mapping[str, Any]):
"""Read-only diagnostic values with explicitly deferred calculations."""
def __init__(self, values: Mapping[str, Any]):
self._values = MappingProxyType(dict(values))
def __getitem__(self, key: str) -> Any:
value = self._values[key]
return value() if callable(value) else value
def __iter__(self):
return iter(self._values)
def __len__(self) -> int:
return len(self._values)
def __contains__(self, key: object) -> bool:
return key in self._values
[docs]
@dataclass(frozen=True)
class SParameterResult:
"""Complete selected-port small-signal scattering over a sweep grid.
Around a phase-sensitive operating point, the response is
``delta <b_out> = S delta beta + T conj(delta beta)``. ``matrix`` stores ``S``
and ``conjugate_matrix`` stores ``T``. Both have shape
``(*shape, n_ports, n_ports)`` and use ``[..., output, input]`` indexing in
``ports`` order. ``s(output, input)`` and ``t(output, input)`` select
individual entries.
The passive-linear route reports zero for ``T``. ``numpy.asarray(result)``
returns ``matrix``.
Attributes
----------
frequencies : scalar or array_like
Probe frequencies in GHz.
ports : tuple of str
Port labels in output/input matrix order.
axes : tuple
Sweep-axis ``(name, values)`` pairs. ``shape`` is the sweep shape.
diagnostics : tuple of mapping
Per-point solver diagnostics.
matrix, conjugate_matrix : array_like
``S`` and phase-conjugating ``T`` arrays with shape
``(*shape, n_ports, n_ports)``.
shape : tuple of int
Sweep-grid shape preceding the matrix axes.
"""
# The stationary route computes both matrices from one shifted-Liouvillian
# factorization.
frequencies: Any
ports: tuple[str, ...]
axes: tuple[tuple[str, Any], ...]
shape: tuple[int, ...]
diagnostics: tuple[Mapping[str, Any], ...]
matrix: Any
conjugate_matrix: Any
def __post_init__(self) -> None:
object.__setattr__(
self,
"diagnostics",
tuple(item if isinstance(item, _LazyDiagnostics) else _LazyDiagnostics(item) for item in self.diagnostics),
)
@property
def axis_names(self) -> tuple[str, ...]:
"""Names of the result axes, in array order."""
return tuple(name for name, _ in self.axes)
[docs]
def s(self, output: Any, input: Any) -> Any:
"""Return ``S(output, input)`` over the sweep grid.
Parameters
----------
output, input : port object or str
Output row and input column.
"""
return self.matrix[..., self._index(output), self._index(input)]
[docs]
def t(self, output: Any, input: Any) -> Any:
"""Return phase-conjugating ``T(output, input)`` over the sweep grid.
Parameters
----------
output, input : port object or str
Output row and input column.
"""
return self.conjugate_matrix[..., self._index(output), self._index(input)]
@property
def s11(self) -> Any:
"""Return reflection from the first selected plane back onto itself."""
return self.matrix[..., 0, 0]
@property
def s21(self) -> Any:
"""Return transmission from the first selected plane to the second."""
if len(self.ports) < 2:
raise AttributeError("s21 requires at least two ports.")
return self.matrix[..., 1, 0]
def _index(self, plane: Any) -> int:
label = resolve_label(plane)
try:
return self.ports.index(label)
except ValueError:
raise KeyError(f"Port {label!r} is not in this result. Available: {list(self.ports)}") from None
def __array__(self) -> np.ndarray:
return np.asarray(self.matrix)
# A port sees or excites a pole when their overlap exceeds this fraction of their norms.
_DARK = 1e-10
[docs]
@dataclass(frozen=True)
class ResonanceResult:
"""Poles of the weak-probe response between selected ports.
The weak-probe routes of :meth:`VNA.sweep` give the response
``S(f) = S_d + C (-i 2π f - A)^-1 (-C† S_d)`` with the drift
``A = -iH - C†C/2``. Each eigenvalue ``λ_p`` of ``A`` is a pole of the S
entries where its residue does not vanish, at ``f_p = -Im λ_p / 2π`` with
the response linewidth ``κ_p = -2 Re λ_p``. In the convention of
:class:`SParameterResult`,
``S(f) = T(f) (D + Σ_p r_p / (f - z_p))``, ``z_p = f_p + i κ_p / 4π``,
where ``D`` is :attr:`direct`, the conjugate of ``S_d``, and ``T(f)``
holds the transfers of the port reference planes.
:meth:`scattering` evaluates this sum. The poles are in increasing
frequency. Iteration, an index, or the label of a pole's dominant mode
gives one :class:`Resonance`.
``κ_p`` is the full width at half maximum of ``|r_p / (f - z_p)|²`` in
angular units. Near an isolated pole, where the other poles add little
and the transfers vary slowly, it is the width of ``|S - T D|²``. It
includes port loss, internal loss, Purcell decay and dephasing, so it can
exceed the decay rate of the mode's population. ``f_p`` is the frequency
of the open system, which can differ from the closed dressed frequency of
:meth:`Chip.freq`.
Attributes
----------
frequencies : array_like, shape (poles,)
Pole frequencies ``f_p`` in GHz, in the lab frame.
linewidths : array_like, shape (poles,)
Response linewidths ``κ_p`` in 1/ns.
poles : array_like, shape (poles,)
Complex poles ``z_p`` in GHz.
mode_labels : tuple of str
Modes of :attr:`composition`: the harmonic modes, or the devices whose
first excited levels span the one-excitation states, then the poles
that ``eliminate()`` retained.
ports : tuple of str
Port labels in the output and input order of :attr:`residues`.
route : SolveRoute
The weak-probe route and the reason for it.
"""
frequencies: Any
linewidths: Any
poles: Any
mode_labels: tuple[str, ...]
ports: tuple[str, ...]
route: Any
_eigensystem: Any = field(repr=False)
_problem: Any = field(repr=False)
_transfers: Any = field(repr=False)
@property
def composition(self) -> Any:
"""Weight of each mode in each pole, shape ``(poles, modes)``.
Row p is ``|v_pk|² / Σ_k |v_pk|²`` for the right eigenvector ``v_p`` of
the drift, in :attr:`mode_labels` order. It describes the pole in the
mode basis. It is not an occupation probability, because the drift is
not normal in general. At a repeated pole, the rows of the copies
depend on the chosen eigenvectors. A JAX derivative of it raises,
because JAX does not differentiate the eigenvectors of a general
matrix.
"""
vectors, _ = self._eigensystem.modes()
xp = concrete_array_module(vectors)
weights = xp.abs(vectors.T) ** 2
return weights / xp.sum(weights, axis=1, keepdims=True)
@property
def dominant(self) -> tuple[str, ...]:
"""Mode label with the largest weight in each pole.
The labels need concrete values, so inside ``jax.jit`` they raise.
Select poles by index there.
"""
vectors = self._eigensystem.vectors
if contains_tracer(vectors):
raise ValueError("The dominant labels need concrete values. Inside jax.jit, select poles by index.")
weights = np.abs(np.asarray(vectors)) ** 2
return tuple(self.mode_labels[int(k)] for k in np.argmax(weights, axis=0))
@property
def direct(self) -> Any:
"""Instantaneous scattering ``D`` between the ports, shape ``(ports, ports)``.
It is the conjugate of ``S_d``, in the convention of :class:`SParameterResult`.
"""
problem = self._problem
indices = np.asarray(problem.plane_indices, dtype=int)
scattering = problem.scattering
xp = concrete_array_module(scattering)
return xp.conj(xp.asarray(scattering, dtype=complex)[xp.ix_(indices, indices)])
@property
def residues(self) -> Any:
"""Residue ``r_p`` of each S entry at each pole, shape ``(poles, ports, ports)``.
The entries are ``[pole, output, input]``, before the reference-plane
transfers. At a repeated pole, only the sum over the copies is
defined, and it can cancel in an entry. A JAX derivative of them
raises, as for :attr:`composition`.
"""
return self._residues(*self._eigensystem.modes())
@property
def dark(self) -> Any:
"""Whether the selected ports do not see each pole, shape ``(poles,)``.
The residue of pole p from input i to output j is the product of
``c_j v_p``, how output j sees the right eigenvector ``v_p``, and
``u_p† b_i``, how input i excites it. Here ``c_j`` is row j of ``C``
and ``b_i`` is column i of ``B = -C† S_d``. The pole is dark when no
output sees it, ``|c_j v_p| <= 1e-10 |c_j| |v_p|`` for every j, or no
input excites it, ``|u_p† b_i| <= 1e-10 |u_p| |b_i|`` for every i. Its
residues are then numerically negligible, and it stays an eigenvalue
of the drift, for example of a mode that only a hidden loss channel
reaches. Each port is compared with its own scale, so a weak port does
not make a pole dark: a lossless resonator with a port rate of
``1e-12`` /ns reflects ``-1`` on resonance.
"""
system = self._eigensystem
problem = self._problem
xp = concrete_array_module(system.vectors, problem.couplings, problem.scattering)
indices = np.asarray(problem.plane_indices, dtype=int)
couplings = xp.asarray(problem.couplings, dtype=complex)
rows = couplings[indices]
sources = -couplings.conj().T @ xp.asarray(problem.scattering, dtype=complex)[:, indices]
norm = xp.linalg.norm
seen = xp.abs(rows @ system.vectors) > _DARK * norm(rows, axis=1)[:, None] * norm(system.vectors, axis=0)
excited = (xp.abs(system.inverse @ sources)
> _DARK * norm(system.inverse, axis=1)[:, None] * norm(sources, axis=0))
return ~(xp.any(seen, axis=0) & xp.any(excited, axis=1))
[docs]
def scattering(self, frequencies: Any) -> Any:
"""Return ``S(f)`` from the poles, shape ``(*frequencies.shape, ports, ports)``.
It includes the reference-plane transfers, so on the weak-probe route
it equals :meth:`VNA.sweep` at the same frequencies. It uses the
residues, so a JAX derivative of it raises.
Parameters
----------
frequencies : scalar or array_like
Probe frequencies in GHz.
"""
residues = self.residues
xp = concrete_array_module(residues, frequencies, self.poles)
values = xp.asarray(frequencies, dtype=float)
flat = xp.reshape(values, (-1,))
outbound, inbound = self._transfers(flat)
terms = residues[None] / (flat[:, None, None, None] - self.poles[None, :, None, None])
core = self.direct[None] + xp.sum(terms, axis=1)
matrix = xp.conj(outbound[:, :, None] * inbound[:, None, :]) * core
return xp.reshape(matrix, (*values.shape, len(self.ports), len(self.ports)))
def _residues(self, vectors: Any, inverse: Any) -> Any:
problem = self._problem
xp = concrete_array_module(vectors, problem.couplings, problem.scattering)
indices = np.asarray(problem.plane_indices, dtype=int)
couplings = xp.asarray(problem.couplings, dtype=complex)
sources = -couplings.conj().T @ xp.asarray(problem.scattering, dtype=complex)[:, indices]
rows = (couplings[indices] @ vectors).T
columns = inverse @ sources
# 1/(-i 2π f - λ) = (i/2π)/(f - z); the public convention conjugates the residue.
return xp.conj(1j / (2.0 * np.pi) * rows[:, :, None] * columns[:, None, :])
def __len__(self) -> int:
return len(self.mode_labels)
def __iter__(self):
return (Resonance(self, index) for index in range(len(self)))
def __getitem__(self, key: Any) -> "Resonance":
"""Return the pole at an index, or the one pole that a mode label dominates."""
if isinstance(key, (int, np.integer)):
if not -len(self) <= key < len(self):
raise IndexError(f"Pole index {key} is out of range for {len(self)} poles.")
return Resonance(self, int(key) % len(self))
label = resolve_label(key)
matches = [index for index, name in enumerate(self.dominant) if name == label]
if len(matches) != 1:
raise KeyError(f"{len(matches)} poles have the dominant mode {label!r}. Dominant modes: "
f"{list(self.dominant)}. Select the pole by index.")
return Resonance(self, matches[0])
[docs]
@dataclass(frozen=True)
class Resonance:
"""One pole of a :class:`ResonanceResult`.
Attributes
----------
result : ResonanceResult
The poles that hold this one.
index : int
Position of the pole in increasing frequency.
"""
result: ResonanceResult = field(repr=False)
index: int
@property
def frequency(self) -> Any:
"""Pole frequency in GHz."""
return self.result.frequencies[self.index]
@property
def linewidth(self) -> Any:
"""Response linewidth κ in 1/ns."""
return self.result.linewidths[self.index]
@property
def pole(self) -> Any:
"""Complex pole ``f + iκ/4π`` in GHz."""
return self.result.poles[self.index]
@property
def dominant(self) -> str:
"""Mode label with the largest weight."""
return self.result.dominant[self.index]
@property
def composition(self) -> dict[str, Any]:
"""Weight of each mode, keyed by mode label."""
return dict(zip(self.result.mode_labels, self.result.composition[self.index]))
@property
def residues(self) -> Any:
"""Residue of each S entry, shape ``(ports, ports)``, as ``[output, input]``."""
return self.result.residues[self.index]
@property
def dark(self) -> Any:
"""Whether the selected ports do not see this pole."""
return self.result.dark[self.index]
[docs]
@dataclass(frozen=True)
class MeanFieldResponseResult:
"""Stationary mean output fields from a finite coherent probe.
``values`` stores ``<b_out>`` at every selected plane with shape
``(*shape, n_ports)`` in ``ports`` order. ``incident`` stores the input
amplitude ``beta`` broadcast to ``shape``. ``axes`` lists chip and pump sweep
axes first, followed by ``"amplitude"`` and ``"frequency"`` when those
arguments are arrays.
If no fixed pump leaves a coherent mean at that plane and carrier,
``ratio(plane)`` approaches the corresponding small-signal S-parameter as
``beta`` goes to zero. ``ratio(plane)`` is ``NaN`` where ``beta`` is zero.
The result contains one stationary mean-field branch. It does not encode
sweep-rate hysteresis or metastable branches.
Attributes
----------
ports : tuple of str
Selected output labels in the final array axis.
input : str
Probe input label.
frequencies, amplitudes : scalar or array_like
Probe values in GHz and ``1/sqrt(ns)``.
axes : tuple
Sweep-axis ``(name, values)`` pairs. ``shape`` is their array shape.
diagnostics : tuple of mapping
Per-point stationary-solver diagnostics.
values : array_like
Complex output means with shape ``(*shape, n_ports)``.
incident : array_like
Incident amplitude broadcast over ``shape``.
shape : tuple of int
Sweep-grid shape preceding the port axis.
"""
ports: tuple[str, ...]
input: str
frequencies: Any
amplitudes: Any
axes: tuple[tuple[str, Any], ...]
shape: tuple[int, ...]
diagnostics: tuple[Mapping[str, Any], ...]
values: Any
incident: Any
def __post_init__(self) -> None:
object.__setattr__(
self,
"diagnostics",
tuple(item if isinstance(item, _LazyDiagnostics) else _LazyDiagnostics(item) for item in self.diagnostics),
)
@property
def axis_names(self) -> tuple[str, ...]:
"""Names of the result axes, in array order."""
return tuple(name for name, _ in self.axes)
[docs]
def mean(self, plane: Any) -> Any:
"""Return stationary ``<b_out>`` at ``plane`` with shape ``shape``.
Parameters
----------
plane : port object or str
Output reference plane.
"""
label = resolve_label(plane)
try:
return self.values[..., self.ports.index(label)]
except ValueError:
raise KeyError(f"Port {label!r} is not in this result. Available: {list(self.ports)}") from None
[docs]
def ratio(self, plane: Any) -> Any:
"""Return the stationary ``<b_out>/beta`` at ``plane`` with shape ``shape``.
The result is complex ``NaN`` where the incident ``beta`` is zero. Its
zero-amplitude limit is the corresponding small-signal S-parameter when
no fixed pump leaves a coherent mean at that plane and carrier.
Parameters
----------
plane : port object or str
Output reference plane.
"""
mean = self.mean(plane)
xp = select_array_module(is_jax_array(mean))
incident = xp.asarray(self.incident)
zero = incident == 0
return xp.where(zero, xp.nan + 0j, mean / xp.where(zero, 1.0, incident))
[docs]
@dataclass(frozen=True)
class OutputSpectrumResult:
"""Stationary output-field fluctuation spectra and signal photon fluxes.
``signal_fluctuation_spectrum`` is device-generated spectral excess, with
input-system interference for thermal fields. It can be negative.
``added_noise_spectrum`` is directly propagated thermal and amplifier noise,
and ``total_fluctuation_spectrum`` is their sum. ``signal_photon_flux`` is
the propagated device-field flux, split into ``signal_coherent_flux`` and
``signal_incoherent_flux``. These fluxes exclude added noise, because
converting a spectral density to flux requires a detection bandwidth.
Attributes
----------
port : str
Output port label.
frequencies : array_like
Offset frequencies in GHz.
total_fluctuation_spectrum, signal_fluctuation_spectrum, added_noise_spectrum : array_like
Normal-ordered spectral densities in frequency order.
signal_photon_flux, signal_coherent_flux, signal_incoherent_flux : scalar
Propagated flux terms in photons/ns.
steady_state : SteadyStateResult
Captured stationary state used for the spectra.
fourier_convention : str
Definition of the reported two-sided spectrum. The engineering
correlation is the conjugate of the internal physics correlation.
diagnostics : mapping
The solve's route under ``"route"`` and its name under ``"solver"``,
with the truncation entries of a solve on excitation sectors.
"""
port: str
frequencies: Any
total_fluctuation_spectrum: Any
signal_fluctuation_spectrum: Any
added_noise_spectrum: Any
signal_photon_flux: Any
signal_coherent_flux: Any
signal_incoherent_flux: Any
steady_state: Any
fourier_convention: str = "2 Re integral_0^inf d tau exp(-j 2 pi f tau) C_engineering(tau), j = i"
diagnostics: Mapping[str, Any] = field(default_factory=lambda: _LazyDiagnostics({}), repr=False)
[docs]
@dataclass(frozen=True)
class OutputCorrelationResult:
"""Normalized stationary output-field correlation versus delay.
Attributes
----------
order : {1, 2}
Correlation order.
input_port, output_port : str
Correlation input and delayed output labels.
delays : array_like
Non-negative delays in ns.
values, unnormalized : array_like
Normalized and raw correlation values in delay order.
input_intensity, output_intensity : scalar
Mean photon fluxes used for normalization.
steady_state : SteadyStateResult
Captured stationary state.
normalization : str
Human-readable normalization convention.
diagnostics : mapping
The solve's route under ``"route"`` and its name under ``"solver"``,
with the truncation entries of a solve on excitation sectors.
"""
order: int
input_port: str
output_port: str
delays: Any
values: Any
unnormalized: Any
input_intensity: Any
output_intensity: Any
steady_state: Any
normalization: str
diagnostics: Mapping[str, Any] = field(default_factory=lambda: _LazyDiagnostics({}), repr=False)
@property
def port(self) -> str:
"""Delayed output port, kept for single-port result code."""
return self.output_port
@property
def intensity(self) -> Any:
"""Delayed output intensity, kept for single-port result code."""
return self.output_intensity