Source code for quchip.engine.routes

"""Choose how stationary and static calculations solve, and record the reason.

A stationary calculation solves on one of three kinds of space. A passive
linear chip, or a conserving chip under an infinitesimal probe, keeps its mode
or one-excitation equations. A conserving chip under a finite drive keeps the
product labels with total excitation ``N <= K``. Every other calculation uses
the backend's stationary Liouvillian on the product space, or on the retained
labels of an excitation cutoff. Static queries diagonalize the blocks of a
conserving chip or of an excitation cutoff, and otherwise the full static
model.
"""

from __future__ import annotations

import warnings
from dataclasses import dataclass, field
from math import prod
from types import MappingProxyType
from typing import Any, Callable, Mapping, Sequence

import numpy as np

from quchip.utils.jax_utils import contains_tracer
from quchip.utils.values import DeferredValue

# Above this many product states, a conserving chip without a cutoff solves on sectors.
AUTOMATIC_SECTOR_STATES = 64
# The automatic cutoff starts here and compares K with K + 1.
FIRST_CUTOFF = 2
# Relative change allowed between K and K + 1, and the floor as a fraction of each quantity's largest value.
CUTOFF_TOLERANCE = 1e-6
CUTOFF_FLOOR = 1e-9

_LIOUVILLIAN = "stationary_resolvent"
_TRACED = "The values are traced, and the sector solve runs on host NumPy arrays."
WEAK_PROBE: Mapping[str, str] = MappingProxyType({
    "linear_response": "The chip is passive and harmonic, so its mode equations give the small-signal response.",
    "vacuum_response": ("The static model conserves N and every channel lowers or conserves it, so the vacuum is "
                        "stationary and a weak probe keeps the vacuum and the one-excitation states."),
})


[docs] @dataclass(frozen=True) class SolveRoute: """The numerical route of one stationary or static calculation and the reason for it. Attributes ---------- name : str ``"linear_response"`` for passive-linear mode equations, ``"vacuum_response"`` for the vacuum and one-excitation states under a weak probe, ``"conditional_response"`` for a frozen dressed reference, ``"sector_stationary"`` for the excitation sectors ``N <= K``, and ``"stationary_resolvent"`` for the backend's stationary Liouvillian on the product space or on the retained labels of an excitation cutoff. Static queries use ``"sector_eigensystem"`` or ``"full_eigensystem"``. reason : str Why the planner chose the route. dimension : int Number of modes or states that the route solves on. arrays : str Array library of the solve, ``"numpy"`` or ``"jax"``. peak_bytes : int or None Estimated peak memory of the solve, calculated on request. ``None`` means that the route has no estimate. """ name: str reason: str dimension: int arrays: str _peak_bytes: Any = field(default=None, repr=False, compare=False) @property def peak_bytes(self) -> int | None: """Estimated peak memory of the solve in bytes, or ``None``.""" value = self._peak_bytes return value() if callable(value) else value
[docs] @dataclass(frozen=True) class Workflow: """What one public workflow supports. Attributes ---------- name : str Public name of the workflow in messages. cutoff : bool Whether the workflow runs under an :class:`~quchip.ExcitationCutoff`. sectors : bool Whether the planner can solve the workflow on excitation sectors. The sector route needs concrete values, because it solves on host NumPy arrays; traced values use the backend's Liouvillian. """ name: str cutoff: bool sectors: bool
WORKFLOWS: Mapping[str, Workflow] = MappingProxyType({workflow.name: workflow for workflow in ( Workflow("steadystate()", cutoff=True, sectors=True), Workflow("VNA.sweep()", cutoff=False, sectors=False), Workflow("VNA.resonances()", cutoff=False, sectors=False), Workflow("VNA.finite_power()", cutoff=True, sectors=True), Workflow("VNA.measure()", cutoff=True, sectors=True), Workflow("VNA.output_spectrum()", cutoff=True, sectors=True), Workflow("VNA.g1()", cutoff=True, sectors=True), Workflow("VNA.g2()", cutoff=True, sectors=True), Workflow("fit_a_dress(evaluator='local')", cutoff=False, sectors=False), Workflow("eliminate()", cutoff=False, sectors=False), Workflow("eliminate_spectators()", cutoff=False, sectors=False), Workflow("active_patch()", cutoff=False, sectors=False), Workflow("partition()", cutoff=False, sectors=False), )})
[docs] def require_workflow(chip: Any, name: str) -> Workflow: """Return a workflow's capabilities, and raise when the chip's excitation cutoff is outside them. Parameters ---------- chip : Chip Chip whose excitation cutoff is checked. name : str Key of the workflow in :data:`WORKFLOWS`. """ from quchip.cutoff import active_cutoff workflow = WORKFLOWS[name] policy = active_cutoff(chip) if policy is not None and not workflow.cutoff: raise ValueError( f"{workflow.name} does not support an excitation cutoff, and this chip keeps only " f"N <= {policy.max_total}. Use chip.with_space(None) to run it on the full product space." ) return workflow
[docs] def array_name(backend: Any) -> str: """Return the name of a backend's array library.""" return "numpy" if backend.array_module is np else "jax"
[docs] def plan_sectors( chip: Any, workflow: Workflow, *, labels: tuple[str, ...], inputs: tuple[str, ...] = (), modes: tuple[str, ...] = (), frequencies: Sequence[Any] = (), options: Any = None, concrete: bool = True, ) -> tuple[Any, str]: """Return the sector model of a stationary workflow, or ``None`` for the backend's Liouvillian, and why. A chip with an excitation cutoff, or a chip with more than :data:`AUTOMATIC_SECTOR_STATES` product states, solves on sectors when its static model conserves the total excitation ``N`` and its channels and inputs keep that structure. Solver options select the backend's Liouvillian. Parameters ---------- chip : Chip Chip at one parameter point. workflow : Workflow Calling workflow. labels : tuple of str Output ports that the workflow reads. inputs : tuple of str Ports with coherent inputs. modes : tuple of str Fock modes whose observables the workflow reads. frequencies : sequence Carrier frequency of each coherent input in GHz. options : dict or None Stationary solver options. concrete : bool Whether the frequencies and amplitudes of the inputs are concrete. """ from quchip.cutoff import active_cutoff from quchip.engine.frames import same_frequency from quchip.engine.sector_stationary import sector_probe_model policy = active_cutoff(chip) space = "the product space" if policy is None else f"the labels with N <= {policy.max_total}" if not workflow.sectors: return None, f"{workflow.name} solves with the backend's stationary Liouvillian." if options is not None: return None, f"Solver options select the backend's stationary Liouvillian on {space}." states = prod(chip.dims) if policy is None and states <= AUTOMATIC_SECTOR_STATES: return None, (f"The chip has {states} product states, at most {AUTOMATIC_SECTOR_STATES}, " "so the backend's stationary Liouvillian solves the product space.") if not concrete or contains_tracer(tuple(frequencies)): return None, f"{_TRACED} The backend's stationary Liouvillian solves {space}." if any(not same_frequency(frequencies[0], other) for other in frequencies[1:]): return None, ("Inputs at different frequencies leave no frame in which the sector model is static. " f"The backend's stationary Liouvillian solves {space}.") model, reason = sector_probe_model(chip, labels, inputs, modes) if model is None: return None, f"{reason} The backend's stationary Liouvillian solves {space}." if policy is not None: return model, f"{reason} The excitation cutoff keeps the labels with N <= {policy.max_total}." return model, f"{reason} The chip has {states} product states, more than {AUTOMATIC_SECTOR_STATES}."
[docs] def weak_probe_reason(chip: Any, labels: tuple[str, ...]) -> str: """Return why neither the harmonic nor the vacuum response gives a chip's small-signal matrix.""" from quchip.engine.linear_response import _UnsupportedLinearModel, _sector_response_model try: _sector_response_model(chip, labels) except _UnsupportedLinearModel as error: detail = str(error) or "The model does not keep the excitation structure of the vacuum response." else: detail = "The vacuum response does not support this port network." return f"The chip is not passive and harmonic. {detail}"
[docs] def cutoff_reason(cutoff: int, check: "CutoffCheck | None") -> str: """Return the sentence that explains an automatic cutoff ``K``.""" if check is None: return f" The solve keeps N <= {cutoff}, which holds every label." if check.status == "passed": return (f" The solve keeps N <= {cutoff}, because N <= {check.compared_with} changed the results by " f"{check.change:.1e}, within the tolerance {CUTOFF_TOLERANCE:.0e}. This is a convergence check, " "not an error bound.") return (f" The solve keeps N <= {cutoff}, and the comparison with N <= {check.compared_with} did not run, " "because it does not fit in the available memory.")
[docs] def sector_route(model: Any, cutoff: int, reason: str, *, solved: bool = True) -> SolveRoute: """Return the record of a solve on the sectors ``N <= cutoff``. ``solved=False`` records a result that needed no stationary solve, which has no memory estimate. """ if array_name(model.chip.backend) != "numpy": reason += " The sector solve runs with SciPy on the host, not with the backend's JAX arrays." return SolveRoute("sector_stationary", reason, model.size(cutoff), "numpy", (lambda: model.peak_bytes(cutoff)) if solved else None)
[docs] def sector_diagnostics(model: Any, cutoff: int, solve: Any, reason: str, choice: str, check: "CutoffCheck | None") -> dict[str, Any]: """Return the diagnostics entries of a solve on the sectors ``N <= cutoff``. Parameters ---------- model : SectorProbeModel Sector model of the solve. cutoff : int Kept cutoff ``K``. solve : SectorSolve The stationary solve on ``N <= K``. reason : str Why the planner chose the sector route. choice : {"explicit", "automatic"} Whether ``K`` comes from the chip's excitation cutoff or from the comparison with ``K + 1``. check : CutoffCheck or None The comparison of an automatic ``K``. """ state = solve.state route = sector_route(model, cutoff, reason + (cutoff_reason(cutoff, check) if choice == "automatic" else "")) values: dict[str, Any] = { "solver": route.name, "route": route, "excitation_cutoff": cutoff, "cutoff_choice": choice, "retained_dimension": model.size(cutoff), "excitation_weights": model.weights(cutoff, state), "iterations": solve.iterations, "residual": solve.residual, "trace_error": float(abs(np.trace(state) - 1.0)), "hermiticity_error": float(np.linalg.norm(state - state.conj().T)), "positivity_error": lambda: max(0.0, -float(np.min(np.linalg.eigvalsh(0.5 * (state + state.conj().T))))), } if choice == "automatic": values["cutoff_check"] = None if check is None else check.record() return values
[docs] def liouvillian_route(backend: Any, engine_result: Any, reason: str) -> SolveRoute: """Return the record of a solve with the backend's stationary Liouvillian.""" return SolveRoute(_LIOUVILLIAN, reason, prod(engine_result.dims), array_name(backend), DeferredValue(lambda: backend.stationary_bytes(engine_result)))
[docs] def static_route(chip: Any, structure: Any) -> SolveRoute: """Return how static dressed queries diagonalize a chip. Parameters ---------- chip : Chip Chip whose static model is queried. structure : ExcitationStructure Excitation structure of that static model. """ arrays = array_name(chip.backend) if structure.cutoff is not None: return SolveRoute("sector_eigensystem", f"The excitation cutoff keeps N <= {structure.cutoff}, so a dressed " "query diagonalizes the class of totals that holds its label.", structure.largest_block(structure.cutoff), arrays) if structure.conserves: return SolveRoute("sector_eigensystem", "The static model conserves N, so a dressed query diagonalizes " "the sector of its label.", structure.largest_block(2), arrays) return SolveRoute("full_eigensystem", "The static model does not conserve N, so dressed queries diagonalize " "the full static model. chip.excitation_structure() lists the terms that change N.", prod(chip.dims), arrays)
[docs] @dataclass(frozen=True) class CutoffCheck: """Comparison of an automatic cutoff ``K`` with ``K + 1`` over a whole grid. Attributes ---------- status : {"passed", "not run"} ``"not run"`` means that ``N <= K + 1`` did not fit in memory. compared_with : int The larger cutoff ``K + 1``. change : float or None Largest relative change, or ``None`` when the comparison did not run. states : int Number of labels with ``N <= K + 1``. """ status: str compared_with: int change: float | None states: int
[docs] def record(self) -> dict[str, Any]: """Return the check as diagnostics entries.""" return {"status": self.status, "compared_with": self.compared_with, "change": self.change, "tolerance": CUTOFF_TOLERANCE}
[docs] def automatic_cutoff( maximum: int, solve: Callable[[int, Any], Any], change: Callable[[Any, Any], float], *, fits: Callable[[int], bool], states: Callable[[int], int], complete: Callable[[int, Any], Any] | None = None, ) -> tuple[int, Any, CutoffCheck | None]: """Raise ``K`` from :data:`FIRST_CUTOFF` until ``N <= K + 1`` changes the results by at most the tolerance. This is a convergence check, not an error bound. Parameters ---------- maximum : int Largest total of the chip, where the sectors hold every label. solve : callable ``solve(K, lower)`` returns the results on ``N <= K``, starting from the results ``lower`` at a smaller cutoff, or from ``None``. change : callable ``change(lower, upper)`` returns the largest relative change. fits : callable ``fits(K)`` says whether a solve on ``N <= K`` fits in memory. states : callable ``states(K)`` returns the number of labels with ``N <= K``. complete : callable, optional ``complete(K, results)`` completes results that a comparison solved in part, when ``K`` becomes the kept cutoff. Returns ------- tuple The kept cutoff, its results, and the check, which is ``None`` when the kept cutoff holds every label. """ cutoff = min(FIRST_CUTOFF, maximum) lower = solve(cutoff, None) while cutoff < maximum: upper = cutoff + 1 if not fits(upper): return cutoff, lower, CutoffCheck("not run", upper, None, states(upper)) try: candidate = solve(upper, lower) except MemoryError: return cutoff, lower, CutoffCheck("not run", upper, None, states(upper)) value = change(lower, candidate) if value <= CUTOFF_TOLERANCE: return cutoff, lower, CutoffCheck("passed", upper, value, states(upper)) cutoff, lower = upper, candidate if complete is None else complete(upper, candidate) return cutoff, lower, None
[docs] def largest_change(lower: Sequence[Any], upper: Sequence[Any]) -> float: """Return the largest change ``|a - b| / (max(|a|, |b|) + floor / tolerance)`` over pairs of values.""" norms = [float(np.linalg.norm(value)) for value in (*lower, *upper)] floor = CUTOFF_FLOOR / CUTOFF_TOLERANCE * max(norms, default=0.0) change = 0.0 for a, b in zip(lower, upper, strict=True): scale = max(float(np.linalg.norm(a)), float(np.linalg.norm(b))) + floor if scale > 0.0: change = max(change, float(np.linalg.norm(np.asarray(a) - np.asarray(b))) / scale) return change
[docs] def warn_cutoff(method: str, cutoff: int, size: int, check: CutoffCheck, quantities: str, *, note: str = "", stacklevel: int = 2) -> None: """Warn which truncation an automatic route kept and what its check found.""" text = (f"{method} solved the stationary states on the {size:,} product states with total excitation " f"N <= {cutoff}, not on the full product space.") if check.status == "passed": text += (f" Solving with N <= {check.compared_with} changed the {quantities} by at most " f"{check.change:.1e} (tolerance {CUTOFF_TOLERANCE:.0e}) at every grid point, as a norm of the " f"change over the larger norm plus {CUTOFF_FLOOR / CUTOFF_TOLERANCE:.0e} of the largest norm on " "the grid.") text += note else: text += (f" The comparison with N <= {check.compared_with} ({check.states:,} states) did not run, because " "it does not fit in the available memory, so this truncation is not verified.") text += (" Use chip.with_space(ExcitationCutoff(max_total=K)) to set the truncation, or options={} to solve " "on the full product space.") warnings.warn(text, UserWarning, stacklevel=stacklevel + 1)