"""Continuous-wave scattering through declared Markovian ports."""
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping, Sequence
from functools import partial
from typing import Any
import numpy as np
from quchip.utils.values import DeferredValue
from quchip.backend.containers import PreparedStationary
from quchip.engine.input_output import (
add_port_inputs,
port_operators,
reject_retained_mode_responses,
resolve_stationary_engine,
same_frequency,
stationary_frame_plan,
)
from quchip.engine.linear_response import response_poles, try_build_weak_probe_problem
from quchip.engine.conditional_response import build_conditional_response
from quchip.results.measurement import VNAMeasurement
from quchip.engine.output_network import output_mixing
from quchip.engine.reference import cw_transfer, has_amplifier, ReferenceFilter, ReferenceLoss
from quchip.engine.ir import CanonicalOperator, EngineResult, LinearResponseProblem, SteadyStateProblem
from quchip.engine.routes import (
WEAK_PROBE, SolveRoute, array_name, automatic_cutoff, largest_change, plan_sectors, require_workflow,
sector_diagnostics, warn_cutoff, weak_probe_reason,
)
from quchip.engine.steady_state import solve_steadystate_problem
from quchip.results.input_output import (
MeanFieldResponseResult,
OutputCorrelationResult,
OutputSpectrumResult,
ResonanceResult,
SParameterResult,
_LazyDiagnostics,
)
from quchip.results.steady_state import SteadyStateResult
from quchip.sweep import Sweep, ZippedSweep, _axis_metadata, _iter_axis_points
from quchip.utils.jax_utils import concrete_array_module, contains_tracer, maybe_concrete_scalar
from quchip.utils.labeling import bare_label_from_mapping, resolve_label
from quchip.utils.memory import fits_memory
from quchip.engine.field_statistics import canonical as _canonical_matrix, normal_spectrum, propagate_noise
_TONE_PREFIX = "__vna_tone_"
_VACUUM_REFERENCE = ("The when= reference is the vacuum, which is stationary, so a weak probe keeps the vacuum and "
"the one-excitation states.")
_FROZEN_REFERENCE = ("The when= reference freezes one dressed state, so the response solves the coherences between "
"its sector and the adjacent sectors.")
@dataclass(frozen=True)
class _OperatingPoint:
prepared: PreparedStationary
state: SteadyStateResult
tones: tuple[tuple[str, Any, Any], ...]
@property
def engine(self) -> EngineResult:
return self.prepared.engine_result
@property
def diagnostics(self) -> Mapping[str, Any]:
values = {
"solver": "stationary_resolvent",
"route": self.state.diagnostics["route"],
"residual": self.state.residual,
"trace_error": self.state.trace_error,
"positivity_error": DeferredValue(partial(getattr, self.state, "positivity_error")),
"condition_number": partial(getattr, self.state, "condition_number"),
}
space = self.engine.excitation_space
if space is not None:
values.update(excitation_cutoff=max(space.totals), retained_dimension=space.size)
return _LazyDiagnostics(values)
[docs]
@dataclass(frozen=True)
class PortTone:
"""A fixed coherent input field that enters through one declared port.
Attributes
----------
port : str
Resolved label of the external network port.
freq : scalar
Carrier frequency in GHz.
amplitude : scalar
Complex incident field amplitude in ``1/sqrt(ns)``.
"""
port: str
freq: Any
amplitude: Any
_index: int
_owner: "VNA"
[docs]
def vary(self, field: str, values: Any, *, name: str | None = None) -> Sweep:
"""Create a sweep axis for this tone's frequency or amplitude.
Parameters
----------
field : {"freq", "amplitude"}
Tone attribute to vary. Frequency is in GHz and amplitude is in
``1/sqrt(ns)``.
values : array_like
Values in sweep order.
name : str or None, optional
Public name of the result axis. The default is ``"<port>.<field>"``.
Returns
-------
Sweep
Axis for :meth:`VNA.sweep`, :meth:`VNA.finite_power`, or
:meth:`VNA.measure`.
"""
if field not in {"freq", "amplitude"}:
raise ValueError(f"A port tone can vary only 'freq' or 'amplitude', got {field!r}.")
return _ToneAxis(
values,
name=f"{_TONE_PREFIX}{self._index}_{field}",
owner=self._owner,
public_name=name or f"{self.port}.{field}",
)
class _ToneAxis(Sweep):
"""Sweep axis owned by one VNA tone; ``public_name`` labels the result axis."""
def __init__(self, values: Any, *, name: str, owner: "VNA", public_name: str) -> None:
super().__init__(values, name=name)
self.owner = owner
self.public_name = public_name
[docs]
class VNA:
"""Small-signal scattering between selected instrument ports of one chip.
``VNA(chip)`` selects every exposed instrument port in the chip's ``PortNetwork``.
To select a subset, pass exposed port objects or their labels as ``ports``. A bare
label string is rejected.
S-parameters follow the engineering ``e^{+jωt}`` convention, where ``j = i``. Probe
and pump amplitudes, returned fields, correlations, and IQ statistics use this
convention, while internal mode observables keep the physics convention.
Parameters
----------
chip : Chip
Chip with a ``PortNetwork``.
ports : sequence of port objects or str, optional
Selected external ports. ``None`` selects all external ports.
"""
def __init__(self, chip: Any, *, ports: Sequence[Any] | None = None) -> None:
self.chip = chip
if ports is None:
if chip.port_network is None:
raise ValueError("VNA requires a chip with a PortNetwork.")
ports = chip.port_network.external_ports
elif isinstance(ports, str) or not isinstance(ports, Sequence):
raise TypeError("VNA ports must be a sequence of network ports or labels.")
self.ports = tuple(_resolve_exposure(chip, plane) for plane in ports)
if not self.ports:
raise ValueError("VNA requires at least one port.")
if len(set(self.ports)) != len(self.ports):
raise ValueError(f"VNA ports must be unique, got {list(self.ports)}.")
self._tones: list[PortTone] = []
[docs]
def pump(self, port: Any, *, freq: Any, amplitude: Any) -> PortTone:
"""Add a fixed coherent pump and return its sweep handle.
Parameters
----------
port : port object or str
External port receiving the pump.
freq : scalar
Pump frequency in GHz.
amplitude : scalar
Complex incident field in ``1/sqrt(ns)``.
Returns
-------
PortTone
Handle to vary the pump frequency or amplitude.
"""
tone = PortTone(_resolve_exposure(self.chip, port), freq, amplitude, len(self._tones), self)
self._tones.append(tone)
return tone
[docs]
@staticmethod
def zip(*variations: Sweep) -> ZippedSweep:
"""Pair sweep axes element by element instead of a product.
Parameters
----------
*variations : Sweep
Axes with equal lengths.
Returns
-------
ZippedSweep
Composite sweep evaluated at corresponding points.
"""
return Sweep.zip(*variations)
[docs]
def sweep(
self,
frequencies: Any,
*variations: Sweep | ZippedSweep,
when: Mapping[Any, int] | None = None,
options: dict | None = None,
progress: bool = False,
) -> SParameterResult:
"""Sweep the selected ports' complete small-signal matrix around fixed pumps.
The result contains every ``S(output, input)`` between the selected ports.
Its ``matrix`` has shape ``(*sweep_axes, n_ports, n_ports)`` and is indexed
``[..., output, input]``. Ordinary ``Sweep`` axes name paths in
``chip.parameters``, rebind the chip at each point, and can mix with
pump-tone axes. At each frequency, the passive-linear route solves one
multi-right-hand-side mode-space system. The stationary route solves one
pumped operating point, then uses one shifted-Liouvillian factorization for
every input port. A pump-free model that is static in the lab frame uses
one stationary state per chip point and a resolvent at each probe frequency.
Parameters
----------
frequencies : scalar or array_like
Probe frequencies in GHz.
*variations : Sweep or ZippedSweep
Chip and pump axes. Cartesian axes combine and zipped axes vary together.
when : mapping of device objects or labels to int, optional
Frozen dressed-reference label. Unspecified devices have level zero.
Supports zero or one total excitation, static conserving models and
vacuum inputs. ``None`` uses the ordinary stationary response.
An excited reference is fixed in the probe source, while its response
includes all loss channels. It does not model an evolving preparation.
options : dict or None, optional
Stationary solver options. ``None`` permits the passive-linear path.
Explicit ``when`` requires ``None``.
progress : bool, default=False
Show a progress bar for stationary solves.
Returns
-------
SParameterResult
Matrix with shape ``(*shape, n_ports, n_ports)`` and indexing
``[..., output, input]``.
"""
require_workflow(self.chip, "VNA.sweep()")
freq_values, freq_is_axis = _axis_values(frequencies)
self._validate_variations(variations, reserved=("frequency",) if freq_is_axis else ())
variation_shape, variation_points = _iter_axis_points(variations)
shape = variation_shape
if freq_is_axis:
shape += (len(freq_values),)
labels = self.ports
xp = self.chip.backend.array_module
axes = _public_axes(variations)
if freq_is_axis:
axes += (("frequency", freq_values),)
def result(diagnostics: Any, matrix: Any, conjugate: Any) -> SParameterResult:
block = (*shape, len(labels), len(labels))
return SParameterResult(
frequencies=freq_values if freq_is_axis else freq_values[0],
ports=labels,
axes=axes,
shape=shape,
diagnostics=tuple(diagnostics),
matrix=xp.conj(xp.reshape(matrix, block)),
conjugate_matrix=xp.conj(xp.reshape(conjugate, block)),
)
reference = None
if when is not None:
if not isinstance(when, Mapping):
raise TypeError("VNA when must be a mapping of device objects or labels to integer levels.")
reference = bare_label_from_mapping(tuple(d.label for d in self.chip.devices), when, {})
if any(isinstance(level, (bool, np.bool_)) or not isinstance(level, (int, np.integer)) or level < 0
for level in reference) or sum(reference) > 1:
raise ValueError("Conditional VNA requires integer levels with zero or one total excitation.")
if self._tones:
raise ValueError("Conditional VNA does not support fixed pumps.")
if options is not None:
raise ValueError("Conditional VNA does not accept stationary solver options.")
chip_points = [(self._tone_values(p), self._chip_at(p)) for _, p in variation_points]
if reference is not None:
conditional = self._conditional_sweep([chip for _, chip in chip_points], freq_values, labels, reference)
matrix = xp.concatenate(conditional[0])
return result(conditional[1], matrix, xp.zeros_like(matrix))
space = "The backend's stationary Liouvillian solves the product space."
if self._tones:
reason = f"Pump tones need the stationary state of the pumped chip. {space}"
elif options is not None:
reason = "Solver options select the backend's stationary Liouvillian on the product space."
else:
linear = self._linear_sweep([chip for _, chip in chip_points], freq_values, labels)
if not isinstance(linear, str):
matrix = xp.concatenate(linear[0])
return result(linear[1], matrix, xp.zeros_like(matrix))
reason = f"{linear} {space}"
points = [(tones, chip, frequency) for tones, chip in chip_points for frequency in freq_values]
iterator: Any = points
if progress:
from tqdm import tqdm
iterator = tqdm(points, desc="VNA")
blocks: list[tuple[Any, Any]] = []
diagnostics: list[Mapping[str, Any]] = []
previous: tuple[Any, Any, _OperatingPoint] | None = None
lab_operating: _OperatingPoint | None = None
for tones, chip, frequency in iterator:
same_point = previous is not None and previous[0] is tones and previous[1] is chip
if not same_point and not tones:
reject_retained_mode_responses(chip)
engine = chip._resolve(frame="lab")
lab_operating = None if engine.dynamic_terms else _solve_engine(chip, engine, tones, options,
reason=reason)
if lab_operating is not None:
operating = lab_operating
else:
guess = previous[2].state.state if previous is not None and same_point else None
operating = _operating_point(chip, tones, frequency, labels, options, guess=guess, reason=reason)
previous = (tones, chip, operating)
blocks.append(_small_signal_matrix(
operating, labels, frequency, offset=frequency if lab_operating is not None else 0.0,
))
diagnostics.append(operating.diagnostics)
return result(diagnostics, *(xp.stack(part) for part in zip(*blocks, strict=True)))
[docs]
def resonances(self) -> ResonanceResult:
"""Return the poles of the weak-probe response between the selected ports.
The poles come from the model that :meth:`sweep` solves on its
``"linear_response"`` and ``"vacuum_response"`` routes, without a fit.
Each eigenvalue ``λ_p`` of the drift ``A = -iH - C†C/2`` gives a pole
frequency ``f_p = -Im λ_p / 2π`` in GHz and a response linewidth
``κ_p = -2 Re λ_p`` in 1/ns, which includes port, internal, Purcell and
dephasing broadening. The frequencies and linewidths stay
differentiable under JAX on a fixed structure away from degenerate
poles. Compositions and residues are computed when they are read.
Returns
-------
ResonanceResult
Poles in increasing frequency, with their composition, dominant
mode and residues.
Raises
------
ValueError
If the VNA has pump tones, if no weak-probe route applies, with the
reason that :meth:`sweep` gives, or if two ill-conditioned poles
are too close to resolve, as near an exceptional point.
"""
require_workflow(self.chip, "VNA.resonances()")
if self._tones:
raise ValueError("resonances() describes the weak-probe response without pumps. Remove the pump tones.")
labels = self.ports
built = try_build_weak_probe_problem(self.chip, np.zeros(1), plane_labels=labels)
if built is None:
raise ValueError(f"resonances() needs a weak-probe route of VNA.sweep(). "
f"{weak_probe_reason(self.chip, labels)}")
problem, name = built
route = SolveRoute(name, WEAK_PROBE[name], len(problem.mode_labels), array_name(self.chip.backend))
system = response_poles(problem)
xp = concrete_array_module(system.values)
chip = self.chip
def transfers(frequencies: Any) -> tuple[Any, Any]:
# The route does not depend on the probe frequencies, so the rebuilt problem exists.
rebuilt_problem = try_build_weak_probe_problem(chip, frequencies, plane_labels=labels)
assert rebuilt_problem is not None
rebuilt = rebuilt_problem[0]
indices = list(rebuilt.plane_indices)
return rebuilt.outbound_transfer[:, indices], rebuilt.inbound_transfer[:, indices]
return ResonanceResult(
frequencies=-xp.imag(system.values) / (2.0 * np.pi),
linewidths=-2.0 * xp.real(system.values),
# The public convention conjugates the pole i λ / 2π.
poles=xp.conj(1j * system.values / (2.0 * np.pi)),
mode_labels=problem.mode_labels,
ports=labels,
route=route,
_eigensystem=system,
_problem=problem,
_transfers=transfers,
)
[docs]
def finite_power(
self,
frequencies: Any,
amplitudes: Any,
*variations: Sweep | ZippedSweep,
input: Any | None = None,
options: dict | None = None,
progress: bool = False,
) -> MeanFieldResponseResult:
"""Solve the stationary mean output fields for a finite coherent probe.
``frequencies`` are in GHz. ``amplitudes`` are incident field amplitudes
``beta`` in ``1/sqrt(ns)``. Complex values encode phase. The probe enters
``input`` beside any fixed pumps. ``input`` defaults to the selected port
when only one is selected, and is required otherwise. A fixed pump on the
probe input is rejected.
At each grid point, the method solves the stationary Liouvillian in the probe
frame. Chip and pump sweep axes precede ``"amplitude"`` and ``"frequency"``.
Every selected port must resolve at the probe frequency. The returned
``MeanFieldResponseResult`` reports ``<b_out>`` with the same reference-plane
and hidden-channel bookkeeping as ``sweep()``. It also stores the incident
``beta``, broadcast over the grid.
This method does not use the passive-linear mode-space shortcut or follow
sweep-rate hysteresis and metastable branches. A chip whose static model
conserves the total excitation ``N``, with ports that lower ``N``, other
channels that lower or conserve it, vacuum inputs, no thermal occupation,
pumps only at the probe frequency and no output network, solves on
excitation sectors. A finite probe couples every ``N`` to ``N ± 1``, so
this route keeps the product states with ``N <= K``. With
``chip.with_space(ExcitationCutoff(max_total=K))``, ``K`` is the chip's.
Without a cutoff, a chip with more than 64 product states starts at
``K = 2``, compares the result with ``K + 1`` at every grid point, raises
``K`` until the change is below ``1e-6`` relative, and warns which ``K``
it kept. This is a convergence check, not an error bound. ``options``
other than ``None`` solve the backend's stationary Liouvillian. Each
entry of ``diagnostics`` records the route and its reason under
``"route"``.
Parameters
----------
frequencies, amplitudes : scalar or array_like
Probe frequencies in GHz and incident fields in ``1/sqrt(ns)``.
*variations : Sweep or ZippedSweep
Chip and pump axes.
input : port object or str, optional
Probe input, required when multiple ports are selected.
options : dict or None, optional
Stationary solver options. Without a cutoff, a dictionary selects the
full product space.
progress : bool, default=False
Show a progress bar.
Returns
-------
MeanFieldResponseResult
Complex means with shape ``(*shape, n_ports)`` and incident fields
with shape ``shape``.
"""
if input is None:
if len(self.ports) != 1:
raise ValueError("finite_power() requires input= when more than one port is selected.")
input_label = self.ports[0]
else:
input_label = _resolve_exposure(self.chip, input)
if input_label not in self.ports:
raise ValueError(f"finite_power() input {input_label!r} is not a selected port {list(self.ports)}.")
if any(tone.port == input_label for tone in self._tones):
raise ValueError(
f"Plane {input_label!r} already carries a fixed pump. The finite_power() probe must be "
"the only tone entering the selected input so that <b_out>/beta is unambiguous."
)
from quchip.analysis.measurement import _acquire
return _acquire(self, frequencies, amplitudes, variations, labels=self.ports,
input_label=input_label, options=options, progress=progress)
def _validate_variations(
self, variations: tuple[Sweep | ZippedSweep, ...], *, reserved: Sequence[str] = ()
) -> None:
names = list(reserved)
chip_paths = set(self.chip.parameters)
for variation in variations:
members = variation.sweeps if isinstance(variation, ZippedSweep) else (variation,)
for member in members:
if isinstance(member, _ToneAxis):
if member.owner is not self:
raise ValueError(
"VNA tone variations must come from tone.vary(...) on a tone created by this VNA."
)
elif member.name not in chip_paths:
raise KeyError(
f"Unknown Chip parameter path {member.name!r} in VNA sweep. "
f"Available paths: {sorted(chip_paths)}"
)
if isinstance(variation, ZippedSweep):
names.append(_public_name(member))
names.extend(name for name, _ in _public_axes(variations))
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"VNA axis names must be unique, got duplicates {duplicates}.")
[docs]
def measure(
self, frequencies: Any, amplitudes: Any, *variations: Sweep | ZippedSweep,
input: Any = None, outputs: Sequence[Any] | None = None, noise_frequencies: Any = None,
options: dict | None = None, progress: bool = False,
) -> VNAMeasurement:
"""Capture stationary means and joint physical noise before you choose a receiver.
Frequencies are probe frequencies in GHz and amplitudes are in
1/sqrt(ns). Optional noise_frequencies are an increasing symmetric grid
of offsets in GHz, including zero. The default spans ±0.1 GHz with
logarithmic spacing down to 1 Hz. Choose a grid that covers the model's
noise features. The returned data supports receiver integration and
Gaussian sampling without another solve, but it is not a
quantum-trajectory distribution. The method also captures internal
Fock-mode amplitudes and occupations, which you query with
mode_amplitude(device) and photon_number(device).
An excitation-conserving chip solves on the product states with
``N <= K``, as :meth:`finite_power` describes. Without a cutoff, the
automatic check also compares the noise spectra on the requested offset
grid when the ``K + 1`` space has at most 1024 states.
Parameters
----------
frequencies, amplitudes : scalar or array_like
Probe frequencies in GHz and incident fields in ``1/sqrt(ns)``.
*variations : Sweep or ZippedSweep
Chip and pump axes.
input : port object or str, optional
Probe input, required when multiple ports are selected.
outputs : sequence of port objects or str, optional
Captured outputs. ``None`` selects all selected ports.
noise_frequencies : array_like or None, optional
Symmetric increasing offset grid in GHz, including zero.
options : dict or None, optional
Stationary solver options. ``None`` permits the passive-linear
shortcut and the excitation sectors. A dictionary selects the
backend's stationary Liouvillian.
progress : bool, default=False
Show a progress bar.
Returns
-------
VNAMeasurement
Means and physical noise spectra reusable by receiver processing.
"""
from quchip.analysis.measurement import measure
return measure(self, frequencies, amplitudes, variations, input=input, outputs=outputs,
noise_frequencies=noise_frequencies, options=options, progress=progress)
[docs]
def output_spectrum(
self,
output: Any,
*,
frequencies: Any,
options: dict | None = None,
) -> OutputSpectrumResult:
"""Return the normally ordered output fluctuation spectrum.
Frequencies are offsets in GHz from the stationary tone frame. The
coherent carrier is reported separately because it is a delta peak,
not a finite sampled spectral density.
A chip with pumps at one frequency solves on excitation sectors under
the conditions of :meth:`finite_power`, and chooses ``K`` as it does.
``result.diagnostics["route"]`` records the route and its reason.
Parameters
----------
output : port object or str
Output plane.
frequencies : scalar or array_like
Offset frequencies in GHz.
options : dict or None, optional
Stationary solver options. A dictionary selects the backend's
stationary Liouvillian.
"""
workflow = require_workflow(self.chip, "VNA.output_spectrum()")
output_port = _resolve_exposure(self.chip, output)
frequency_values, _ = _axis_values(frequencies)
sector, reason = self._sector_output(workflow, (output_port,), options, frequency_values)
if sector is not None:
return _sector_spectrum(sector, output_port, frequency_values)
operating = self._stationary_output(output_port, options, reason=reason)
engine, state, tones = operating.engine, operating.state, operating.tones
operators = port_operators(engine, self.chip.backend)
incoming = _stationary_output_backgrounds(engine, tones, self.chip.backend)
xp = self.chip.backend.array_module
rho = xp.asarray(self.chip.backend.to_array(state.state), dtype=complex)
channel = next(item for item in engine.slh.external_channels if item.key == output_port)
carrier = _output_carrier(engine, output_port, tones)
all_fields = xp.stack([_output_field_matrix(operators[item.key], incoming.get(item.key, 0.0), xp)
for item in engine.slh.channels])
index = next(i for i, item in enumerate(engine.slh.channels) if item.key == output_port)
boundary_field = xp.einsum("i,ijk->jk", output_mixing(engine.slh, carrier, xp)[index], all_fields)
field = cw_transfer(channel.reference.outbound, carrier, xp) * boundary_field
field_rho = field @ rho
mean = xp.trace(field_rho)
intensity = xp.real(xp.einsum("ij,ij->", field.conj(), field_rho))
coherent_flux = xp.abs(mean) ** 2
from quchip.analysis.measurement import capture_noise
components, _ = capture_noise(operating, self.chip.backend, (output_port,), carrier,
xp.asarray(frequency_values))
def source_spectrum(pair: tuple[Any, Any]) -> Any:
white, excess = pair
return normal_spectrum(white + excess, xp)
signal = source_spectrum(components["device.correlations"])
added_noise = sum((source_spectrum(pair) for name, pair in components.items()
if name != "device.correlations"), xp.zeros_like(frequency_values))
return OutputSpectrumResult(
port=output_port,
frequencies=frequency_values,
total_fluctuation_spectrum=xp.asarray(signal + added_noise),
signal_fluctuation_spectrum=xp.asarray(signal),
added_noise_spectrum=added_noise,
signal_photon_flux=intensity,
signal_coherent_flux=coherent_flux,
signal_incoherent_flux=intensity - coherent_flux,
steady_state=state,
diagnostics=operating.diagnostics,
)
[docs]
def g1(
self,
output: Any,
delays: Any,
*,
input: Any | None = None,
options: dict | None = None,
) -> OutputCorrelationResult:
"""Return normalized first-order output coherence ``g1``.
The excitation sectors apply as in :meth:`output_spectrum`.
Parameters
----------
output : port object or str
Delayed output plane.
delays : scalar or array_like
Non-negative delays in ns.
input : port object or str, optional
Cross-correlation input. The default is ``output``.
options : dict or None, optional
Stationary solver options. A dictionary selects the backend's
stationary Liouvillian.
Returns
-------
OutputCorrelationResult
Normalized and raw correlations on the delay grid.
"""
return self._correlation(output, delays, input=input, order=1, options=options)
[docs]
def g2(
self,
output: Any,
delays: Any,
*,
input: Any | None = None,
options: dict | None = None,
) -> OutputCorrelationResult:
"""Return normalized second-order output intensity correlation ``g2``.
The excitation sectors apply as in :meth:`output_spectrum`.
Parameters
----------
output : port object or str
Delayed output plane.
delays : scalar or array_like
Non-negative delays in ns.
input : port object or str, optional
Cross-correlation input. The default is ``output``.
options : dict or None, optional
Stationary solver options. A dictionary selects the backend's
stationary Liouvillian.
Returns
-------
OutputCorrelationResult
Normalized and raw second-order correlations on the delay grid.
"""
return self._correlation(output, delays, input=input, order=2, options=options)
def _correlation(
self,
output: Any,
delays: Any,
*,
input: Any | None,
order: int,
options: dict | None,
) -> OutputCorrelationResult:
workflow = require_workflow(self.chip, f"VNA.g{order}()")
output_port = _resolve_exposure(self.chip, output)
input_port = output_port if input is None else _resolve_exposure(self.chip, input)
delay_values, _ = _axis_values(delays)
if not contains_tracer(delay_values) and np.any(np.asarray(delay_values) < 0):
raise ValueError("Stationary output correlations require non-negative delays.")
sector, reason = self._sector_output(workflow, tuple(dict.fromkeys((output_port, input_port))), options,
delay_values)
if sector is not None:
return _sector_correlation(sector, order, output_port, input_port, delay_values)
operating = self._stationary_output(output_port, options, reason=reason)
engine, state, tones = operating.engine, operating.state, operating.tones
backend = self.chip.backend
operators = port_operators(engine, backend)
incoming = _stationary_output_backgrounds(engine, tones, backend)
xp = backend.array_module
rho = xp.asarray(backend.to_array(state.state), dtype=complex)
runs = {item.key: item.reference.outbound for item in engine.slh.external_channels}
_require_detectable(runs, [(channel.input_occupation, channel.reference) for channel in engine.slh.channels],
output_port, input_port, mixed=engine.slh.output_network is not None)
def plane_field(key: str) -> Any:
factor = cw_transfer(runs[key], _output_carrier(engine, key, tones), xp)
return factor * _output_field_matrix(operators[key], incoming.get(key, 0.0), xp)
output_field = plane_field(output_port)
input_field = plane_field(input_port)
output_field_dag = xp.conj(xp.swapaxes(output_field, -1, -2))
input_field_dag = xp.conj(xp.swapaxes(input_field, -1, -2))
output_number = output_field_dag @ output_field
input_number = input_field_dag @ input_field
output_intensity = xp.real(xp.einsum("ij,ji->", output_number, rho))
input_intensity = xp.real(xp.einsum("ij,ji->", input_number, rho))
concrete_output = maybe_concrete_scalar(output_intensity)
concrete_input = maybe_concrete_scalar(input_intensity)
if (
concrete_output is not None
and concrete_output <= 0
or concrete_input is not None
and concrete_input <= 0
):
raise ValueError("Normalized output correlations require nonzero output intensity.")
initial = _canonical_matrix(
input_field @ rho if order == 1 else input_field @ rho @ input_field_dag,
operators[input_port],
tag=f"g{order}-initial:{input_port}",
)
observable = _canonical_matrix(
output_field_dag if order == 1 else output_number,
operators[output_port],
tag=f"g{order}-observable:{output_port}",
)
raw = backend.stationary_propagate(
engine,
initial,
((output_port, observable),),
delay_values,
prepared=operating.prepared,
)[output_port]
denominator = (
xp.sqrt(input_intensity * output_intensity)
if order == 1
else input_intensity * output_intensity
)
raw = xp.conj(raw)
return OutputCorrelationResult(
order=order,
input_port=input_port,
output_port=output_port,
delays=delay_values,
values=raw / denominator,
unnormalized=raw,
input_intensity=input_intensity,
output_intensity=output_intensity,
steady_state=state,
normalization=_normalization(order, input_port == output_port),
diagnostics=operating.diagnostics,
)
def _stationary_output(
self,
output_label: str,
options: dict | None,
*,
reason: str = "The backend's stationary Liouvillian solves the output statistics.",
) -> _OperatingPoint:
"""Solve stationary output statistics in the applicable reference frames.
Pump tones define the stationary frames when present; otherwise the requested
output plane's own reference frequency does.
"""
tones = self._tone_values({})
engine = resolve_stationary_engine(self.chip, self._output_frames(output_label, tones))
driven_engine = add_port_inputs(engine, self.chip.backend, tones)
return _solve_engine(self.chip, driven_engine, tones, options, reason=reason)
def _output_frames(self, output_label: str, tones: tuple[tuple[str, Any, Any], ...]) -> tuple[tuple[str, Any], ...]:
"""Return the tone frequencies, or the output's reference frequency without tones."""
return tuple((label, frequency) for label, frequency, _ in tones) or (
(output_label, _exposure_reference_frequency(self.chip, output_label)),
)
def _sector_output(
self, workflow: Any, labels: tuple[str, ...], options: dict | None, grid: Any,
) -> tuple["_SectorOutput | None", str]:
"""Return the sector plan of a stationary output workflow, or ``None``, and the reason.
``labels`` lists the output first. ``grid`` holds the offsets or delays,
which the host solve needs as concrete values.
"""
tones = self._tone_values({})
model, reason = plan_sectors(
self.chip, workflow, labels=labels, inputs=tuple(port for port, _, _ in tones),
frequencies=tuple(frequency for _, frequency, _ in tones), options=options,
concrete=not contains_tracer((tones, grid)))
if model is None:
return None, reason
from quchip.engine.assembly import _prepare_engine_assembly
frames = self._output_frames(labels[0], tones)
plan, resolution = stationary_frame_plan(self.chip, frames)
_, frame = _prepare_engine_assembly(self.chip, plan, self.chip.approximation, resolution=resolution)
return _SectorOutput(model, reason, workflow.name, float(frames[0][1]),
tuple(complex(amplitude) for _, _, amplitude in tones), frame), reason
def _linear_sweep(
self, chips: list[Any], frequencies: Any, labels: tuple[str, ...]
) -> tuple[list[Any], list[Mapping[str, Any]]] | str:
"""Solve every chip point in mode space, or return why a point needs the stationary route."""
matrices: list[Any] = []
diagnostics: list[Mapping[str, Any]] = []
for chip in chips:
built = try_build_weak_probe_problem(chip, frequencies, plane_labels=labels)
if built is None:
return weak_probe_reason(chip, labels)
problem, name = built
route = SolveRoute(name, WEAK_PROBE[name], len(problem.mode_labels), array_name(chip.backend))
solved = chip.backend.linear_response(problem)
indices = list(problem.plane_indices)
transfer = (
problem.outbound_transfer[:, indices][:, :, None]
* problem.inbound_transfer[:, indices][:, None, :]
)
matrices.append(chip.backend.array_module.asarray(transfer * solved.responses))
diagnostics.extend(
_LazyDiagnostics({
"solver": name,
"route": route,
"mode_count": len(problem.mode_labels),
"residual": solved.residuals[index],
"condition_number": lambda index=index, condition=solved._condition_numbers: condition()[index],
})
for index in range(len(frequencies))
)
return matrices, diagnostics
def _conditional_sweep(
self, chips: list[Any], frequencies: Any, labels: tuple[str, ...], reference: tuple[int, ...],
) -> tuple[list[Any], list[Mapping[str, Any]]]:
"""Solve a fixed-reference response at each parameter point."""
matrices: list[Any] = []
diagnostics: list[Mapping[str, Any]] = []
for chip in chips:
problem, metadata = build_conditional_response(chip, frequencies, labels, reference)
vacuum = isinstance(problem, LinearResponseProblem)
metadata["route"] = SolveRoute(
metadata["solver"], _VACUUM_REFERENCE if vacuum else _FROZEN_REFERENCE,
metadata["mode_count"] if vacuum else metadata["coherence_count"], array_name(chip.backend))
solved = chip.backend.linear_response(problem) if vacuum else chip.backend.coherence_response(problem)
indices = list(problem.plane_indices)
transfer = problem.outbound_transfer[:, indices, None] * problem.inbound_transfer[:, None, indices]
matrices.append(transfer * solved.responses)
def condition(index: int, value: Any = solved._condition_numbers) -> Any:
values = value()
return None if values is None else values[index]
diagnostics.extend(_LazyDiagnostics({
**metadata, "residual": solved.residuals[index],
"condition_number" if vacuum else "block_condition_number": partial(condition, index),
}) for index in range(len(frequencies)))
return matrices, diagnostics
def _chip_at(self, params: dict[str, Any]) -> Any:
"""Return the chip after rebinding the ordinary sweep values in ``params``."""
bindings = {name: value for name, value in params.items() if name not in self._tone_keys()}
return self.chip.with_params(bindings) if bindings else self.chip
def _tone_keys(self) -> set[str]:
return {
f"{_TONE_PREFIX}{tone._index}_{field}"
for tone in self._tones
for field in ("freq", "amplitude")
}
def _tone_values(self, params: dict[str, Any]) -> tuple[tuple[str, Any, Any], ...]:
tones: list[tuple[str, Any, Any]] = []
for tone in self._tones:
freq = params.get(f"{_TONE_PREFIX}{tone._index}_freq", tone.freq)
amplitude = params.get(f"{_TONE_PREFIX}{tone._index}_amplitude", tone.amplitude)
tones.append((tone.port, freq, self.chip.backend.array_module.conj(amplitude)))
return tuple(tones)
def _axis_values(values: Any) -> tuple[Any, bool]:
if np.ndim(values) == 0:
return (values,), False
array = values if hasattr(values, "shape") else np.asarray(values)
if len(array) == 0:
raise ValueError("VNA frequency sweep cannot be empty.")
return array, True
def _resolve_exposure(chip: Any, value: Any) -> str:
"""Find an external network port by object or label."""
label = resolve_label(value)
network = chip.port_network
if network is None:
raise ValueError("VNA requires a chip with a PortNetwork.")
available = [exposure.label for exposure in network.external_ports]
if label not in available:
raise ValueError(f"Unknown VNA port {label!r}. Available network ports: {available}.")
return label
def _exposure_reference_frequency(chip: Any, label: str) -> Any:
"""Return the exposure carrier in the chip's natural rotating frame."""
resolved = chip.resolve(frame="rotating")
for channel in resolved.slh.external_channels:
if channel.key == label:
frequency = channel.collapse.frame_frequency
if frequency is None and resolved.slh.output_network is not None:
carriers = [item.collapse.frame_frequency for item in resolved.slh.channels
if item.collapse.frame_frequency is not None]
if carriers and all(same_frequency(carriers[0], other) for other in carriers[1:]):
return carriers[0]
return 0.0 if frequency is None else frequency
available = [channel.key for channel in resolved.slh.external_channels]
raise ValueError(f"Unknown VNA port {label!r}. Available network ports: {available}.")
def _public_name(sweep: Sweep) -> str:
return sweep.public_name if isinstance(sweep, _ToneAxis) else sweep.name
def _public_axes(variations: tuple[Sweep | ZippedSweep, ...]) -> tuple[tuple[str, Any], ...]:
public = {
sweep.name: sweep.public_name
for axis in variations
for sweep in (axis.sweeps if isinstance(axis, ZippedSweep) else (axis,))
if isinstance(sweep, _ToneAxis)
}
return _axis_metadata(variations, rename=lambda name: public.get(name, name))
def _operating_point(
chip: Any,
tones: tuple[tuple[str, Any, Any], ...],
frequency: Any,
labels: tuple[str, ...],
options: dict | None,
*,
guess: Any | None = None,
reason: str,
) -> _OperatingPoint:
"""Solve the pumped stationary state with every selected plane framed at ``frequency``.
``guess`` is the operating state at another probe frequency; the backend
may reuse it when it remains stationary in these frames. ``reason``
explains the route in the diagnostics.
"""
engine = resolve_stationary_engine(
chip,
tuple((label, tone_frequency) for label, tone_frequency, _ in tones)
+ tuple((label, frequency) for label in labels),
)
operating_engine = add_port_inputs(engine, chip.backend, tones)
return _solve_engine(chip, operating_engine, tones, options, guess=guess, reason=reason)
def _solve_engine(
chip: Any, engine: EngineResult, tones: tuple[tuple[str, Any, Any], ...], options: dict | None,
*, guess: Any | None = None, reason: str,
) -> _OperatingPoint:
problem = SteadyStateProblem(
chip=chip,
engine_result=engine,
e_ops=None,
e_ops_meta=None,
resolved_frame=engine.resolved_frame,
options={} if options is None else options,
)
prepared = problem.backend.prepare_stationary(engine)
return _OperatingPoint(prepared, solve_steadystate_problem(problem, prepared=prepared, guess=guess, reason=reason),
tones)
def _stationary_output_backgrounds(
engine: EngineResult,
tones: tuple[tuple[str, Any, Any], ...],
backend: Any,
) -> dict[str, Any]:
"""Return ``S beta`` at each Markov boundary for fixed reference-plane tones."""
xp = backend.array_module
external = engine.slh.external_channels
exposure_index = {channel.key: index for index, channel in enumerate(external)}
incident = [xp.asarray(0.0 + 0.0j) for _ in external]
for label, frequency, amplitude in tones:
index = exposure_index[label]
incident[index] = incident[index] + xp.asarray(amplitude) * cw_transfer(
external[index].reference.inbound, frequency, xp
)
return {
channel.key: sum(
(
xp.asarray(engine.slh.S[output_index, input_index]) * beta
for input_index, beta in enumerate(incident)
),
start=xp.asarray(0.0 + 0.0j),
)
for output_index, channel in enumerate(external)
}
def _small_signal_matrix(
operating: _OperatingPoint,
labels: tuple[str, ...],
frequency: Any,
*,
offset: Any = 0.0,
) -> tuple[Any, Any]:
"""Return the selected-port ``S_ji(f)`` and phase-conjugating ``T_ji(f)`` matrices.
Rows are output ports and columns are input ports. Around a phase-sensitive
operating point the linear response is ``delta <b_out> = S delta beta + T
conj(delta beta)``; one shifted-Liouvillian factorization serves both source
sets for all input columns. ``offset`` is the probe frequency relative to
the stationary frame, in GHz. At nonzero offset the conjugate input lies
at the opposite Fourier frequency and contributes no same-carrier ``T``.
"""
engine = operating.engine
backend = operating.prepared.backend
channels = port_operators(engine, backend)
xp = backend.array_module
rho = xp.asarray(backend.to_array(operating.state.state), dtype=complex)
external = engine.slh.external_channels
exposure_index = {channel.key: index for index, channel in enumerate(external)}
indices = [exposure_index[label] for label in labels]
template = channels[external[0].key]
operators = xp.stack(
[xp.asarray(channels[channel.key].to_dense(), dtype=complex) for channel in engine.slh.channels]
)
scattering = xp.asarray(engine.slh.S)
sources = []
for column, index in enumerate(indices):
input_operator = xp.tensordot(xp.conj(scattering[:, index]), operators, axes=1)
input_dag = xp.conj(xp.swapaxes(input_operator, -1, -2))
normal_source = _canonical_matrix(input_dag @ rho - rho @ input_dag, template, tag=f"normal-source:{column}")
conjugate_source = _canonical_matrix(
rho @ input_operator - input_operator @ rho, template, tag=f"conjugate-source:{column}"
)
sources.append((f"normal:{column}", normal_source))
sources.append((f"conjugate:{column}", conjugate_source))
response = backend.stationary_resolvent(
engine,
tuple(sources),
tuple((channel.key, channels[channel.key]) for channel in engine.slh.channels),
(offset,),
prepared=operating.prepared,
)
inbound = xp.stack([cw_transfer(external[i].reference.inbound, frequency, xp) for i in indices])
outbound = xp.stack([cw_transfer(external[i].reference.outbound, frequency, xp) for i in indices])
def gather(kind: str) -> Any:
columns = range(len(labels))
return xp.stack([xp.stack([response[(f"{kind}:{c}", channel.key)][0] for c in columns])
for channel in engine.slh.channels])
direct = scattering[:, xp.asarray(indices)]
mixing = output_mixing(engine.slh, frequency, xp)[xp.asarray(indices)]
gain = outbound[:, None]
return (
gain * inbound[None, :] * (mixing @ (direct + gather("normal"))),
xp.where(xp.asarray(offset) == 0.0,
gain * xp.conj(inbound)[None, :] * (mixing @ gather("conjugate")), 0.0),
)
def _output_carrier(engine: EngineResult, key: str, tones: Any) -> Any:
"""Return the carrier a plane's outbound run is evaluated at.
A coupling-free composed output inherits the unique tone that structurally
feeds it; without one, an outbound reference run has no defined carrier.
"""
external = {item.key: index for index, item in enumerate(engine.slh.external_channels)}
row = external[key]
channel = engine.slh.external_channels[row]
if channel.collapse.frame_frequency is not None or (not channel.reference.outbound
and engine.slh.output_network is None):
return channel.carrier
feeding = [frequency for label, frequency, _ in tones if engine.slh.feeds(row, external[label])]
if not feeding or any(not same_frequency(feeding[0], other) for other in feeding[1:]):
raise ValueError(
f"Output plane {key!r} carries no coupling and is fed by {len(feeding)} tones; "
"its outbound reference sections need exactly one carrier."
)
return feeding[0]
def _plane_means(
engine: EngineResult,
state: Any,
backend: Any,
labels: tuple[str, ...],
tones: tuple[tuple[str, Any, Any], ...],
frequency: Any,
) -> Any:
"""Return stationary output means at the selected ports.
For each plane, apply its outbound continuous-wave transfer to
``sum_i S_ji beta_boundary,i + <L_j>`` at ``frequency``. Every selected
plane must resolve at that frequency.
"""
xp = backend.array_module
rho = xp.asarray(backend.to_array(state), dtype=complex)
operators = port_operators(engine, backend)
backgrounds = _stationary_output_backgrounds(engine, tones, backend)
channels = {channel.key: channel for channel in engine.slh.external_channels}
boundaries = xp.stack([backgrounds.get(channel.key, 0.0)
+ xp.einsum("ij,ji->", xp.asarray(operators[channel.key].to_dense()), rho)
for channel in engine.slh.channels])
boundaries = output_mixing(engine.slh, frequency, xp) @ boundaries
means = []
indices = {channel.key: i for i, channel in enumerate(engine.slh.channels)}
for label in labels:
channel = channels[label]
carrier = _output_carrier(engine, label, tones)
# A coupling-free, unilluminated output is zero at any requested carrier.
is_zero = not any(engine.slh.feeds(
indices[label], indices[tone[0]],
) for tone in tones) and channel.collapse.frame_frequency is None
if not is_zero and not same_frequency(carrier, frequency):
raise ValueError(
f"Plane {label!r} resolves at {carrier!r} GHz, not at the probe frequency "
f"{frequency!r} GHz. Narrow VNA(..., ports=...) to ports at the probe carrier."
)
boundary = boundaries[indices[label]]
means.append(cw_transfer(channel.reference.outbound, carrier, xp) * boundary)
return xp.stack(means)
def _output_field_matrix(operator: CanonicalOperator, incoming: Any, xp: Any) -> Any:
coupling = xp.asarray(operator.to_dense(), dtype=complex)
return xp.asarray(incoming) * xp.eye(coupling.shape[0], dtype=complex) + coupling
@dataclass(frozen=True)
class _SectorOutput:
"""The sector model of a stationary output workflow, with its coherent inputs at one carrier frequency."""
model: Any
reason: str
method: str
frequency: float
amplitudes: tuple[complex, ...]
frame: Any
def _sector_solve(sector: _SectorOutput, measure: Any, change: Any, quantities: str,
stacklevel: int) -> tuple[int, Any, str, Any]:
"""Solve on the chip's cutoff, or on an automatic one, and measure the stationary state.
Returns the kept cutoff, the solve with its measured values, the choice
of the cutoff and the check of an automatic one.
"""
from quchip.analysis.measurement import _embed
model = sector.model
def solve(cutoff: int, lower: Any) -> Any:
guess = None if lower is None else _embed(lower[0].state, model.size(cutoff))
solved = model.stationary(cutoff, sector.frequency, sector.amplitudes, guess)
return solved, measure(cutoff, solved.state)
if model.sectors.cutoff is not None:
return model.sectors.cutoff, solve(model.sectors.cutoff, None), "explicit", None
cutoff, results, check = automatic_cutoff(
model.max_total, solve, lambda lower, upper: change(lower[1], upper[1]), states=model.size,
fits=lambda total: fits_memory(model.required_memory(total)))
if check is not None:
warn_cutoff(sector.method, cutoff, model.size(cutoff), check, quantities, stacklevel=stacklevel)
return cutoff, results, "automatic", check
def _sector_spectrum(sector: _SectorOutput, output_port: str, offsets: Any) -> OutputSpectrumResult:
"""Return the output spectrum of a sector solve, in the frame of the coherent inputs."""
model, frequency, amplitudes = sector.model, sector.frequency, sector.amplitudes
grid = np.asarray(offsets, dtype=float)
def source(pair: tuple[Any, Any]) -> Any:
white, excess = pair
return normal_spectrum(white + excess, np)
def measure(cutoff: int, state: Any) -> tuple[Any, ...]:
excess, info = model.quadrature_spectrum(cutoff, frequency, amplitudes, state, grid)
components, _ = propagate_noise(model.fields, model.scattering, None, excess, (output_port,), frequency,
grid, np)
added = sum((source(pair) for name, pair in components.items() if name != "device.correlations"),
np.zeros(len(grid)))
field = model.field(cutoff, frequency, amplitudes, output_port)
intensity = float(np.real((field.conj().T @ field).multiply(state.T).sum()))
coherent = abs(complex(field.multiply(state.T).sum())) ** 2
return source(components["device.correlations"]), added, intensity, coherent, info
# The warning names the caller of VNA.output_spectrum().
cutoff, (solved, (signal, added, intensity, coherent, info)), choice, check = _sector_solve(
sector, measure, _spectrum_change, "spectra and photon fluxes", stacklevel=4)
diagnostics = {**sector_diagnostics(model, cutoff, solved, sector.reason, choice, check), **info}
xp = model.chip.backend.array_module
return OutputSpectrumResult(
port=output_port,
frequencies=offsets,
total_fluctuation_spectrum=xp.asarray(signal + added),
signal_fluctuation_spectrum=xp.asarray(signal),
added_noise_spectrum=xp.asarray(added),
signal_photon_flux=xp.asarray(intensity),
signal_coherent_flux=xp.asarray(coherent),
signal_incoherent_flux=xp.asarray(intensity - coherent),
steady_state=model.steady_state(cutoff, solved, sector.frame, diagnostics),
diagnostics=_LazyDiagnostics(diagnostics),
)
def _spectrum_change(lower: tuple[Any, ...], upper: tuple[Any, ...]) -> float:
"""Compare two spectrum solves at each offset, and their total and coherent photon fluxes.
The fluxes share the floor of the total flux, which bounds the coherent one.
"""
return max(largest_change(list(lower[0]), list(upper[0])), largest_change(list(lower[1]), list(upper[1])),
largest_change(lower[2:4], upper[2:4]))
def _correlation_change(lower: tuple[Any, ...], upper: tuple[Any, ...]) -> float:
"""Compare two correlation solves at each delay, and each of their two intensities."""
return max(largest_change(list(lower[0]), list(upper[0])), largest_change(lower[1:2], upper[1:2]),
largest_change(lower[2:3], upper[2:3]))
def _sector_correlation(sector: _SectorOutput, order: int, output_port: str, input_port: str,
delays: Any) -> OutputCorrelationResult:
"""Return a normalized output correlation of a sector solve, in the frame of the coherent inputs."""
model, frequency, amplitudes = sector.model, sector.frequency, sector.amplitudes
references = model.response.references
_require_detectable({key: reference.outbound for key, reference in zip(model.keys, references, strict=True)},
[(None, reference) for reference in references], output_port, input_port, mixed=False)
grid = np.asarray(delays, dtype=float)
def measure(cutoff: int, state: Any) -> tuple[Any, float, float]:
values = model.correlation(cutoff, frequency, amplitudes, state, order, output_port, input_port, grid)
if values[1] <= 0 or values[2] <= 0:
raise ValueError("Normalized output correlations require nonzero output intensity.")
return values
# The warning names the caller of VNA.g1() or VNA.g2().
cutoff, (solved, (raw, input_intensity, output_intensity)), choice, check = _sector_solve(
sector, measure, _correlation_change, "correlations and intensities", stacklevel=5)
raw = np.conj(raw)
denominator = np.sqrt(input_intensity * output_intensity) if order == 1 else input_intensity * output_intensity
diagnostics = sector_diagnostics(model, cutoff, solved, sector.reason, choice, check)
xp = model.chip.backend.array_module
return OutputCorrelationResult(
order=order,
input_port=input_port,
output_port=output_port,
delays=delays,
values=xp.asarray(raw / denominator),
unnormalized=xp.asarray(raw),
input_intensity=xp.asarray(input_intensity),
output_intensity=xp.asarray(output_intensity),
steady_state=model.steady_state(cutoff, solved, sector.frame, diagnostics),
normalization=_normalization(order, input_port == output_port),
diagnostics=_LazyDiagnostics(diagnostics),
)
def _require_detectable(runs: Mapping[str, Any], channels: Sequence[tuple[Any, Any]], output_port: str,
input_port: str, *, mixed: bool) -> None:
"""Raise when normalized g1 and g2 need a detection bandwidth.
``runs`` maps each external channel to its outbound reference run, and
``channels`` holds the input occupation and the reference of every channel.
"""
if mixed or has_amplifier(runs[output_port]) or has_amplifier(runs[input_port]):
raise NotImplementedError(
"Normalized g1 and g2 through an amplifier require a detection bandwidth for its "
"broadband added noise. Request the correlation at a reference plane before the amplifier."
)
thermal = any(occupation is not None or any(
(isinstance(element, ReferenceLoss) and element.occupation is not None)
or (isinstance(element, ReferenceFilter) and element.loss_occupation is not None)
for element in (*reference.inbound, *reference.outbound))
for occupation, reference in channels)
if thermal:
raise NotImplementedError("Normalized g1 and g2 with thermal network fields require a detection "
"bandwidth. Use measure().statistics() for integrated second moments.")
def _normalization(order: int, same_port: bool) -> str:
"""Return the normalization convention of an output correlation."""
if order == 1:
return "G1(tau) / G1(0)" if same_port else "G1(output, input; tau) / sqrt(I_output I_input)"
return "G2(tau) / G1(0)^2" if same_port else "G2(output, input; tau) / (I_output I_input)"