Source code for quchip.engine.sector_stationary

"""Stationary states and output statistics of excitation-conserving chips on sectors.

A coherent drive ``i(c* L - c L†)`` couples every total excitation ``N`` to
``N ± 1``, so no finite set of sectors is closed. This module keeps the
product energy labels with ``N <= K`` and the projected model ``P H P`` and
``P L P`` on them. That model differs from the full model only by the drive
elements from ``N = K`` to ``N = K + 1``. Every operator comes from the terms'
local matrices on their supports, so the product space is never formed. The
backend supplies the generator that solves on these labels.
"""

from __future__ import annotations

from typing import Any

import numpy as np
from scipy import sparse

from quchip.engine.linear_response import _UnsupportedLinearModel, _sector_response_model
from quchip.engine.reference import FieldChannel, cw_transfer
from quchip.engine.excitation import sector_levels, sector_size
from quchip.engine.sectors import _level_pairs
from quchip.utils.constants import TWO_PI
from quchip.utils.jax_utils import contains_tracer, maybe_concrete_scalar


[docs] class SectorProbeModel: """Sector operators of one conserving chip under coherent inputs, for any cutoff. Parameters ---------- chip : Chip Chip with a static model that conserves the total excitation. response : _SectorResponseModel The chip's channels, scattering and reference planes on sectors. labels : tuple of str Selected output ports. inputs : tuple of str Ports that carry coherent inputs at one carrier frequency. modes : tuple of str Fock-mode devices whose amplitude and photon number are read. """ def __init__(self, chip: Any, response: Any, labels: tuple[str, ...], inputs: tuple[str, ...], modes: tuple[str, ...]) -> None: self.chip = chip self.response = response sectors = response.sectors self.sectors = sectors self.dims = sectors.dims self.max_total = sum(dimension - 1 for dimension in self.dims) self.keys = response.keys self.inputs = tuple(self.keys.index(label) for label in inputs) self.labels = labels self.modes = modes self.scattering = np.asarray(self.response.scattering, dtype=complex) self.fields = tuple(FieldChannel(key, reference, None) for key, reference in zip(self.keys, self.response.references, strict=True)) conserving = set(self.response.conserving_indices) self.lowering_indices = tuple(k for k in range(len(self.response.channels)) if k not in conserving) self.conserving_indices = tuple(sorted(conserving)) self._operators: dict[int, tuple[Any, tuple[Any, ...], tuple[Any, ...]]] = {} self._generators: dict[int, Any] = {}
[docs] def size(self, cutoff: int) -> int: """Return the number of product labels with ``N <= cutoff``.""" return sum(sector_size(self.dims, total) for total in range(cutoff + 1))
[docs] def required_memory(self, cutoff: int) -> float: """Return the bytes that a solve on ``N <= cutoff`` needs at the shortest Krylov restart.""" return self.chip.backend.sector_solve_bytes(self.sector_sizes(cutoff), smallest=True)
[docs] def peak_bytes(self, cutoff: int) -> int: """Return the estimated peak bytes of a solve on ``N <= cutoff``.""" return self.chip.backend.sector_solve_bytes(self.sector_sizes(cutoff))
[docs] def sector_sizes(self, cutoff: int) -> tuple[int, ...]: """Return the size of each sector ``N = 0..cutoff``.""" return tuple(sector_size(self.dims, total) for total in range(cutoff + 1))
def _levels(self, cutoff: int) -> np.ndarray: return np.concatenate([sector_levels(self.dims, total) for total in range(cutoff + 1)]) def _sparse(self, levels: np.ndarray, matrix: Any, support: tuple[int, ...]) -> Any: """Return an operator on ``support`` as a sparse matrix on the retained labels.""" rows, columns, local_rows, local_columns = _level_pairs(self.dims, support, levels, levels) values = np.asarray(matrix, dtype=complex)[local_rows, local_columns] keep = values != 0 size = len(levels) return sparse.csr_matrix((values[keep], (rows[keep], columns[keep])), shape=(size, size))
[docs] def operators(self, cutoff: int) -> tuple[Any, tuple[Any, ...], tuple[Any, ...]]: """Return the Hamiltonian, the channels and the mode operators on ``N <= cutoff``. The Hamiltonian is in rad/ns, in the lab frame and relative to the vacuum energy. The channels follow the SLH order. Each mode has its lowering operator and its number operator. """ if cutoff in self._operators: return self._operators[cutoff] from quchip.engine.observables import prepare_local_op from quchip.engine.assembly import _energy_operator sectors, levels = self.sectors, self._levels(cutoff) size = len(levels) diagonal = np.zeros(size) for index, energies in enumerate(sectors.energies): diagonal = diagonal + np.asarray(energies, dtype=float)[levels[:, index]] hamiltonian = sparse.diags(diagonal - diagonal[0]).astype(complex).tocsr() for operator, support in sectors.terms: hamiltonian = hamiltonian + self._sparse(levels, operator, support) channels = tuple( sum((complex(coefficient) * self._sparse(levels, matrix, support) for coefficient, matrix, support in parts), start=sparse.csr_matrix((size, size), dtype=complex)) for parts in self.response.channels) backend, labels = self.chip.backend, tuple(device.label for device in self.chip.devices) modes = [] for label in self.modes: index, device = labels.index(label), self.chip.device_map[label] local = [_energy_operator(prepare_local_op(device, spec, sectors.bases[label], backend), (label,), sectors.bases, backend) for spec in ("a", "n")] modes.append(tuple(self._sparse(levels, matrix, (index,)) for matrix in local)) self._operators[cutoff] = (TWO_PI * hamiltonian.tocsr(), channels, tuple(modes)) return self._operators[cutoff]
[docs] def generator(self, cutoff: int) -> Any: """Return the backend's truncated generator on ``N <= cutoff``.""" if cutoff not in self._generators: hamiltonian, channels, _ = self.operators(cutoff) self._generators[cutoff] = self.chip.backend.sector_generator( self.sector_sizes(cutoff), hamiltonian, [channels[k] for k in self.lowering_indices], [channels[k] for k in self.conserving_indices]) return self._generators[cutoff]
[docs] def unique(self, cutoff: int) -> bool: """Return whether the lowering channels prove one stationary state on ``N <= cutoff``.""" _, channels, _ = self.operators(cutoff) return self.chip.backend.sector_unique(self.sector_sizes(cutoff), [channels[k] for k in self.lowering_indices])
[docs] def steady_state(self, cutoff: int, solve: Any, frame: Any, diagnostics: Any) -> Any: """Return a solve on ``N <= cutoff`` as a steady-state result on those labels. ``frame`` is the frame of the coherent inputs, in which the solve is static. The lowering channels check that the state is unique, and an unproven state reports ``nullity=None``. """ from types import MappingProxyType from quchip.engine.pulse_space import PulseSpace from quchip.results.steady_state import SteadyStateResult chip, backend = self.chip, self.chip.backend space = PulseSpace.create(self.dims, tuple(device.label for device in chip.devices), tuple(range(cutoff + 1)), self.sectors.bases) unique = not self.generator(cutoff).dark return SteadyStateResult( state=backend.from_array(solve.state, dims=[[space.size], [space.size]]), residual=solve.residual, nullity=1 if unique else None, _condition_number=None, dims=space.dims, device_info=tuple((device.label, device.computational) for device in chip.devices), stats=MappingProxyType({"iterations": solve.iterations, "uniqueness_checked": unique}), _backend=backend, _expectations=MappingProxyType({}), frame=frame, excitation_space=space, diagnostics=MappingProxyType(dict(diagnostics)), )
[docs] def incident(self, frequency: float, amplitudes: Any) -> np.ndarray: """Return each input at its Markov boundary, in the physics convention and ``1/sqrt(ns)``.""" return np.asarray([ complex(amplitude) * complex(cw_transfer(self.response.references[index].inbound, frequency, np)) for index, amplitude in zip(self.inputs, amplitudes, strict=True)], dtype=complex)
[docs] def backgrounds(self, frequency: float, amplitudes: Any) -> np.ndarray: """Return the coherent field ``sum_i S_ki beta_i`` that the inputs carry to each channel ``k``.""" if not self.inputs: return np.zeros(len(self.keys), dtype=complex) return self.scattering[:, list(self.inputs)] @ self.incident(frequency, amplitudes)
[docs] def drive(self, frequency: float, amplitudes: Any) -> np.ndarray: """Return the drive coefficient ``c`` of each lowering channel.""" return self.backgrounds(frequency, amplitudes)[list(self.lowering_indices)]
[docs] def stationary(self, cutoff: int, frequency: float, amplitudes: Any, guess: np.ndarray | None = None) -> Any: """Solve the stationary state on ``N <= cutoff`` for coherent inputs at ``frequency``.""" return self.generator(cutoff).stationary(frequency, self.drive(frequency, amplitudes), guess)
[docs] def field(self, cutoff: int, frequency: float, amplitudes: Any, label: str) -> Any: """Return the output field ``h (b I + L)`` of one port on ``N <= cutoff`` as a sparse matrix. ``b`` is the coherent field that the inputs carry to the port, ``L`` the port's channel and ``h`` its outbound transfer at ``frequency``. """ _, channels, _ = self.operators(cutoff) index = self.keys.index(label) background = self.backgrounds(frequency, amplitudes)[index] gain = complex(cw_transfer(self.response.references[index].outbound, frequency, np)) identity = sparse.identity(self.size(cutoff), dtype=complex, format="csr") return (gain * (background * identity + channels[index])).tocsr()
[docs] def means(self, cutoff: int, frequency: float, amplitudes: Any, state: np.ndarray) -> np.ndarray: """Return ``<b_out>`` at the selected ports in the physics convention.""" _, channels, _ = self.operators(cutoff) backgrounds = self.backgrounds(frequency, amplitudes) values = [] for label in self.labels: index = self.keys.index(label) boundary = backgrounds[index] + channels[index].multiply(state.T).sum() values.append(complex(cw_transfer(self.response.references[index].outbound, frequency, np)) * boundary) return np.asarray(values)
[docs] def mode_values(self, cutoff: int, state: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Return ``<a>`` and ``<a†a>`` of each mode in the common probe frame.""" _, _, modes = self.operators(cutoff) amplitudes = np.asarray([lowering.multiply(state.T).sum() for lowering, _ in modes], dtype=complex) numbers = np.asarray([number.multiply(state.T).sum().real for _, number in modes], dtype=float) return amplitudes, numbers
[docs] def weights(self, cutoff: int, state: np.ndarray) -> tuple[float, ...]: """Return the population of each total excitation ``N = 0..cutoff``.""" offsets = np.concatenate(([0], np.cumsum(self.sector_sizes(cutoff)))) diagonal = np.real(np.diag(state)) return tuple(float(np.sum(diagonal[offsets[n]:offsets[n + 1]])) for n in range(cutoff + 1))
[docs] def quadrature_spectrum(self, cutoff: int, frequency: float, amplitudes: Any, state: np.ndarray, offsets: Any) -> tuple[np.ndarray, dict[str, Any]]: """Return the normally ordered excess IQ spectrum of the selected ports. It follows :func:`quchip.engine.field_statistics.quadrature_spectrum` for vacuum inputs. The source of output ``p`` is ``(L_p - <L_p>) rho``. The adjoint source at ``f`` needs the resolvent at ``-f``, so the solve covers every offset and its negative. Only an exact negative reuses a point, so an exactly symmetric grid needs no extra solves. """ _, channels, _ = self.operators(cutoff) generator, drive = self.generator(cutoff), self.drive(frequency, amplitudes) offsets = np.asarray(offsets, dtype=float) grid, mirror = list(offsets), [] for value in -offsets: match = np.flatnonzero(offsets == value) if match.size: mirror.append(int(match[0])) else: grid.append(float(value)) mirror.append(len(grid) - 1) indices = [self.keys.index(label) for label in self.labels] observables = [operator for index in indices for operator in (channels[index], channels[index].conj().T)] count = len(indices) # values[f, r, p, 0] = Tr(L_r Y_p(f)) and values[f, r, p, 1] = Tr(L_r† Y_p(f)). solved_values = np.zeros((len(grid), count, count, 2), dtype=complex) iterations, residual, trace = 0, 0.0, 0.0 for p, index in enumerate(indices): lowering = channels[index] mean = lowering.multiply(state.T).sum() source = lowering @ state - mean * state solved, steps, error, drift = generator.resolvent(frequency, drive, source, grid, observables) solved_values[:, :, p, :] = solved.reshape(len(grid), count, 2) iterations, residual, trace = max(iterations, steps), max(residual, error), max(trace, drift) values, reverse = solved_values[:len(offsets)], solved_values[mirror] factors = (1.0, -1j) causal = np.zeros((len(offsets), 2 * count, 2 * count), dtype=complex) for r in range(count): for a, fa in enumerate(factors): for p in range(count): direct = (fa * values[:, r, p, 0] + np.conj(fa) * values[:, r, p, 1]) / 2 adjoint = (fa * np.conj(reverse[:, r, p, 1]) + np.conj(fa) * np.conj(reverse[:, r, p, 0])) / 2 for b, fb in enumerate(factors): # The regression source is minus the conditioned emission. causal[:, 2 * r + a, 2 * p + b] = -(fb * direct + np.conj(fb) * adjoint) / 2 excess = causal + np.conj(np.swapaxes(causal, -1, -2)) return excess, {"noise_iterations": iterations, "noise_residual": residual, "noise_trace_error": trace}
[docs] def correlation(self, cutoff: int, frequency: float, amplitudes: Any, state: np.ndarray, order: int, output: str, input: str, delays: Any) -> tuple[np.ndarray, float, float]: """Return a raw stationary output correlation and the two intensities, in the physics convention. ``G1(τ) = Tr(b_out† e^{Gτ}[b_in rho])`` and ``G2(τ) = Tr(b_out† b_out e^{Gτ}[b_in rho b_in†])``, where each field ``b`` keeps its coherent background as :meth:`field` gives it. """ output_field = self.field(cutoff, frequency, amplitudes, output) input_field = self.field(cutoff, frequency, amplitudes, input) output_number = (output_field.conj().T @ output_field).tocsr() input_number = (input_field.conj().T @ input_field).tocsr() output_intensity = float(np.real(output_number.multiply(state.T).sum())) input_intensity = float(np.real(input_number.multiply(state.T).sum())) initial = input_field @ state if order == 2: initial = (input_field @ initial.conj().T).conj().T observable = output_field.conj().T.tocsr() if order == 1 else output_number raw = self.generator(cutoff).propagate(frequency, self.drive(frequency, amplitudes), initial, delays, [observable])[:, 0] return raw, input_intensity, output_intensity
[docs] def sector_probe_model(chip: Any, labels: tuple[str, ...], inputs: tuple[str, ...], modes: tuple[str, ...]) -> tuple[SectorProbeModel | None, str]: """Return the sector model of a chip under coherent inputs, or ``None``, with the reason. The route needs a static model that conserves ``N``, ports that lower ``N`` by one, other channels that lower or conserve it, vacuum inputs, no thermal device occupation, no output network, no retained mode responses and concrete values. """ network = chip.port_network if network is not None and network._mode_responses: return None, "The port network keeps retained mode responses." if contains_tracer(dict(chip.parameters)): return None, "The values are traced, and the sector solve runs on host NumPy arrays." thermal = [device.label for device in chip.devices if device.thermal_occupation is not None and maybe_concrete_scalar(device.thermal_occupation) != 0.0] if thermal: return None, f"Device {thermal[0]!r} has a thermal occupation, so its channels also raise N." try: response = _sector_response_model(chip, labels) except _UnsupportedLinearModel as error: return None, str(error) or "The model does not keep the excitation structure of the sector route." sectors = response.sectors references = tuple(value for reference in response.references for element in (*reference.inbound, *reference.outbound) for value in element.tracked_values) if sectors.traced or contains_tracer((response.channels, response.scattering, references)): return None, "The values are traced, and the sector solve runs on host NumPy arrays." # Under a cutoff, the sector model exists for any chip; a conserving model keeps one total per class. if sectors.classes is not None and any(len(group) > 1 for group in sectors.classes): return None, "The static model does not conserve the total excitation N." return SectorProbeModel(chip, response, labels, inputs, modes), "The static model conserves N."