"""QuTiP backend implementation of the :mod:`quchip.backend.protocol` contract.
QuTiP stores operators as ``qutip.Qobj`` (sparse CSR by default, dense on
request). This backend lowers engine IR into ``Qobj`` / ``QobjEvo`` form and
dispatches the ``sesolve``/``mesolve`` drivers directly.
The reusable ``loky`` process pool parallelizes batched sweeps and
heterogeneous problem lists.
References
----------
* Johansson, Nation, Nori — *QuTiP 2*, Comput. Phys. Commun. 183, 1760 (2012)
* Breuer & Petruccione — *The Theory of Open Quantum Systems* (OUP, 2002)
"""
# QuTiP solvers release the GIL only partially, so processes are faster than
# threads. The workers do the final `QobjEvo` assembly, so the main process
# never pays for it.
from __future__ import annotations
import cmath
import math
import multiprocessing
import os
import warnings
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, Sequence
import numpy as np
import qutip
from qutip import Qobj
from qutip.solver.mesolve import MESolver
from qutip.solver.sesolve import SESolver
from scipy import sparse
from quchip.utils.memory import dense_bytes, fits_memory, require_memory, sparse_lu_bytes
from quchip.backend._response import coherence_response, linear_response, stationary_condition_number
from quchip.utils.values import DeferredValue
from quchip.backend._dims import (
default_solver_steps,
validate_two_body_indices,
)
from quchip.backend.containers import (
BatchSolveError,
_DEFAULT_SOLVE_OPTIONS,
EigensystemData,
DeferredBatch,
PreparedHamiltonian,
PreparedStationary,
LinearResponseSolverResult,
CoherenceResponseSolverResult,
SolverResult,
SteadyStateSolverResult,
)
from quchip.backend.protocol import Backend, Operator, State
from quchip.engine.ir import (
decompose_carrier_bands,
evaluate_signal_program,
signal_window_bounds,
)
from quchip.utils.jax_utils import contains_tracer, maybe_concrete_scalar
@dataclass(frozen=True)
class _QuTiPBatchShared:
"""Shared per-batch state travelling through :attr:`DeferredBatch.shared`.
Opaque to the engine; consumed by :meth:`QuTiPBackend.solve_batch` to
build one ``QobjEvo`` per element inside a loky worker.
"""
static_rhs: tuple[Qobj | None, ...]
dynamic_qobjs: tuple[tuple[Qobj, ...], ...]
sample_tlist: Any
dynamic_signals: tuple[tuple[Any, ...], ...]
@dataclass(frozen=True)
class _QuTiPBatchTask:
"""One compact point handed to a sequential or loky batch worker."""
static_rhs: Qobj | None
dynamic_qobjs: tuple[Qobj, ...]
dynamic_signals: tuple[Any, ...]
initial_state: Any
c_ops: tuple[Qobj, ...]
solver_name: str
options: dict[str, Any]
e_ops: tuple[Qobj, ...] | None
def _coeff_callable(signal: Any) -> Callable[[float, Any], complex]:
"""Build a scalar coefficient callable ``c(t)`` from a signal program AST."""
def _coeff(time: float, _args: Any = None) -> complex:
value = np.asarray(evaluate_signal_program(signal, time, xp=np), dtype=complex)
return complex(value.item()) if value.ndim == 0 else complex(value.reshape(-1)[0])
return _coeff
# Resolve slow, carrier-free envelopes even with a two-point output tlist.
_MIN_ENVELOPE_SAMPLES = 1001
# Window cost scales with pulse width, not idle-span length; the floor resolves sub-ns pulses.
_WINDOW_SUBGRID_POINTS_PER_NS = 40.0
_MIN_WINDOW_INTERIOR_SAMPLES = 41
# Zero-valued margins expose each discontinuity; a sparse skeleton covers the remaining zero plateau.
_WINDOW_EDGE_PADDING_NS = 1.0
_CANONICAL_BASE_SKELETON_POINTS = 3
def _collect_window_bounds(signal: Any) -> list[tuple[float, float]]:
"""Concrete absolute windows used to refine QuTiP's coefficient grid."""
bounds = []
for start, stop in signal_window_bounds(signal):
start, stop = maybe_concrete_scalar(start), maybe_concrete_scalar(stop)
if start is not None and stop is not None and stop > start:
bounds.append((start, stop))
return bounds
def _local_window_subgrid(start: float, stop: float) -> np.ndarray:
"""Resolve a window with exact edge nodes, at least 41 interior points, and zero-valued margins."""
width = stop - start
interior_n = max(int(np.ceil(width * _WINDOW_SUBGRID_POINTS_PER_NS)) + 1, _MIN_WINDOW_INTERIOR_SAMPLES)
interior = np.linspace(start, stop, interior_n)
pad = min(_WINDOW_EDGE_PADDING_NS, max(width, 1e-9))
outside_n = max(int(np.ceil(pad * _WINDOW_SUBGRID_POINTS_PER_NS)), 3)
before = np.linspace(start - pad, start, outside_n, endpoint=False)
after = np.linspace(stop, stop + pad, outside_n + 1)[1:]
return np.unique(np.concatenate([before, interior, after, [start, stop]]))
def _bracketed_feature_times(local: np.ndarray, offsets: tuple[float, ...]) -> np.ndarray:
"""Return absolute feature times with a close knot on each side of each one.
The two knots keep a discontinuity at a feature time sharp in a linear
interpolant, after any pulse start and line delay. The gap never drops below
two float spacings of 1 ns, so a feature at t = 0 gets no subnormal neighbor.
"""
times = local + offsets[-1]
# With n shifts, placing a knot and evaluating the signal there round at most 2n + 1 times.
# Each rounding moves a time by at most one float spacing of twice the largest clock
# magnitude. A gap of 2n + 2 such spacings keeps each knot on its side of the feature.
clock = np.max(np.abs(times[:, None] - np.asarray(offsets)), axis=1)
gap = 2 * len(offsets) * np.spacing(np.maximum(2 * clock, 1.0))
return np.concatenate([times - gap, times, times + gap])
def _augmented_sample_grid(envelope: Any, base_grid: Any) -> np.ndarray:
"""Return the base grid for an envelope without windows.
Otherwise, combine the solve-span skeleton, the bracketed feature times and
the local window subgrids. Window accuracy must not depend on output-tlist
density or idle-span length.
"""
bounds = _collect_window_bounds(envelope)
base = np.asarray(base_grid, dtype=float)
if not bounds:
return base
lo, hi = base[0], base[-1]
from quchip.engine.sampling import signal_features
pieces = [np.linspace(lo, hi, _CANONICAL_BASE_SKELETON_POINTS)]
# Feature times include every window edge, so a pulse edge and an envelope
# step listed in sampling_times() stay sharp, including at a grid endpoint.
pieces.extend(_bracketed_feature_times(local, offsets) for local, offsets in signal_features(envelope))
for start, stop in bounds:
clipped_start, clipped_stop = max(start, lo), min(stop, hi)
if clipped_stop > clipped_start:
pieces.append(_local_window_subgrid(clipped_start, clipped_stop))
grid = np.unique(np.concatenate(pieces))
return grid[(grid >= lo) & (grid <= hi)]
def _sample_coeff_array(signal: Any, sample_tlist: Any) -> np.ndarray:
"""Sample a signal program over *sample_tlist* as a 1-D complex array.
A time-independent envelope (e.g. a carrier band's ``Constant``) yields
a scalar; broadcast it to the grid length so it matches *sample_tlist*.
"""
arr = np.asarray(evaluate_signal_program(signal, sample_tlist, xp=np), dtype=complex).ravel()
return np.broadcast_to(arr, (len(sample_tlist),)).copy()
# Linear interpolation cannot overshoot the discontinuous zero plateau, unlike cubic interpolation
# across dense pulse knots and sparse idle knots. Non-windowed envelopes retain the cubic default.
_WINDOWED_COEFFICIENT_ORDER = 1
# QuTiP 5 assembles a Lindblad superoperator term by term and keeps one term per
# time-dependent Hamiltonian part. With dense inputs, measured assembly peaks near
# six dense D²×D² arrays plus four per time-dependent part. With CSR inputs each
# term is added into a running sum whose output is sized for both operands before
# trimming, so the previous sum, the new term and the new sum coexist: measured
# peaks stay below three copies of the final storage.
_DENSE_SUPEROPERATOR_PEAK_COPIES = 6
_DENSE_SUPEROPERATOR_COPIES_PER_PART = 4
_SPARSE_SUPEROPERATOR_PEAK_COPIES = 3
def _operator_parts(operator: Any) -> list[Qobj]:
"""Return the constant operators of a ``Qobj``, a ``QobjEvo``, or a sequence of them."""
if isinstance(operator, (list, tuple)):
return [part for item in operator for part in _operator_parts(item)]
if isinstance(operator, qutip.QobjEvo):
return [part[0] if isinstance(part, list) else part for part in operator.to_list()]
return [operator]
def _pairs_reaching(magnitudes: np.ndarray, atol: float) -> int:
"""Count ordered pairs of stored magnitudes whose product reaches ``atol``."""
ordered = np.sort(magnitudes)
if atol <= 0.0:
return int(ordered.size**2)
with np.errstate(divide="ignore"):
partners = np.searchsorted(ordered, atol / ordered)
return int(ordered.size**2 - partners.sum())
def _superoperator_peak_bytes(hamiltonian: Any, collapse_ops: Sequence[Any]) -> int:
"""Estimate QuTiP's peak memory while assembling a Lindblad superoperator.
The sparse estimate counts the entries QuTiP keeps. spre(H) and spost(H)
store nnz(H)·D entries each. A jump term c ⊗ c* keeps only pairs of entries
whose product reaches QuTiP's tidy-up tolerance, and the jump terms of all
operators share positions in the running sum, so their union is bounded by
the pairs of the elementwise largest magnitude. Each c†c keeps the entries
where |c|ᵀ|c| reaches the tolerance, and its two lifts store D copies.
"""
hamiltonian_parts = _operator_parts(hamiltonian)
collapse_parts = [_operator_parts(op) for op in collapse_ops]
dimension = int(hamiltonian_parts[0].shape[0])
copies = _DENSE_SUPEROPERATOR_PEAK_COPIES + _DENSE_SUPEROPERATOR_COPIES_PER_PART * (len(hamiltonian_parts) - 1)
dense = copies * 16 * dimension**4
parts = [*hamiltonian_parts, *(part for group in collapse_parts for part in group)]
if any(isinstance(part.data, qutip.data.Dense) for part in parts):
return dense
def magnitude(group: list[Qobj]) -> Any:
matrix = abs(group[0].to("CSR").data_as("csr_matrix"))
for part in group[1:]:
matrix = matrix + abs(part.to("CSR").data_as("csr_matrix"))
return matrix.tocsr()
atol = float(qutip.settings.core["auto_tidyup_atol"]) if qutip.settings.core["auto_tidyup"] else 0.0
entries = 2 * dimension * sum(part.to("CSR").data_as("csr_matrix").nnz for part in hamiltonian_parts)
if collapse_parts:
magnitudes = [magnitude(group) for group in collapse_parts]
# Every part pair of a time-dependent jump keeps its own coefficient.
separate = sum(len(group) ** 2 * _pairs_reaching(matrix.data, atol)
for group, matrix in zip(collapse_parts, magnitudes))
envelope = magnitudes[0]
for matrix in magnitudes[1:]:
envelope = envelope.maximum(matrix)
shared = _pairs_reaching(envelope.data, atol)
entries += separate if any(len(group) > 1 for group in collapse_parts) else min(separate, shared)
products = [(matrix.T @ matrix).tocsr() for matrix in magnitudes]
for product in products:
product.data[product.data < atol] = 0.0
product.eliminate_zeros()
entries += 2 * dimension * sum(products[1:], start=products[0]).nnz
entry_bytes = 16 + np.dtype(qutip.core.data.base.idxint_dtype).itemsize
return min(dense, _SPARSE_SUPEROPERATOR_PEAK_COPIES * entry_bytes * entries)
def _require_superoperator_memory(hamiltonian: Any, collapse_ops: Sequence[Any], *, task: str, remedy: str) -> None:
"""Raise before QuTiP assembles a Liouvillian that cannot fit in memory."""
dimension = int(_operator_parts(hamiltonian)[0].shape[0])
require_memory(
_superoperator_peak_bytes(hamiltonian, collapse_ops),
task=f"{task} at Hilbert dimension D = {dimension}",
remedy=remedy,
)
# Dense canonical payloads with at most this fraction of nonzero entries are
# stored as CSR. Below it CSR uses less memory than dense storage, including in
# every superoperator term built from the operator.
_CSR_MAX_FILL = 0.25
# Cap diag at Hilbert D=64 for sesolve and Liouvillian D²=1024 (Hilbert D=32) for mesolve.
# The mesolve setup scales roughly as D⁶ in time and D⁴ in memory.
_MAX_STATIC_HILBERT_DIM = 64
_MAX_STATIC_LIOUVILLIAN_DIM = 1024
def _envelope_coefficient(envelope: Any, sample_tlist: Any) -> Any:
"""Sample windowed envelopes locally with linear interpolation to avoid edge ringing.
Use the base grid otherwise, or an exact callable when no concrete grid is available."""
if sample_tlist is None:
return qutip.coefficient(_coeff_callable(envelope))
bounds = _collect_window_bounds(envelope)
try:
grid = _augmented_sample_grid(envelope, sample_tlist)
except NotImplementedError:
return qutip.coefficient(_coeff_callable(envelope))
arr = _sample_coeff_array(envelope, grid)
if bounds:
return qutip.coefficient(
np.asarray(arr, dtype=complex),
tlist=np.asarray(grid, dtype=float),
order=_WINDOWED_COEFFICIENT_ORDER,
)
return qutip.coefficient(np.asarray(arr, dtype=complex), tlist=np.asarray(grid, dtype=float))
def _carrier_coefficient(freq: Any) -> Any:
"""Keep exp(i·freq·t) analytic, with angular freq in rad/ns.
QuTiP evaluates the closure only at concrete times; this is not a JAX-traced path."""
rate = 1j * complex(freq)
def _carrier(t: float, *args: Any, **kwargs: Any) -> complex:
return cmath.exp(rate * t)
return qutip.coefficient(_carrier)
def _summed_envelope_coefficient(envelopes: Sequence[Any], sample_tlist: Any) -> Any:
"""Return one coefficient equal to the sum of the envelopes' own coefficients.
Linear interpolants of windowed envelopes add exactly on the union of
their grids, and cubic interpolants on one shared grid add their samples,
since interpolation is linear in the data. Envelopes without a concrete
grid keep their exact callables.
"""
if sample_tlist is None or len(envelopes) == 1:
parts = [_envelope_coefficient(envelope, sample_tlist) for envelope in envelopes]
else:
parts = []
linear: list[tuple[np.ndarray, np.ndarray]] = []
cubic: list[tuple[np.ndarray, np.ndarray]] = []
for envelope in envelopes:
try:
grid = np.asarray(_augmented_sample_grid(envelope, sample_tlist), dtype=float)
except NotImplementedError:
parts.append(qutip.coefficient(_coeff_callable(envelope)))
continue
samples = _sample_coeff_array(envelope, grid)
(linear if _collect_window_bounds(envelope) else cubic).append((grid, samples))
if cubic and all(np.array_equal(grid, cubic[0][0]) for grid, _ in cubic):
parts.append(qutip.coefficient(sum(samples for _, samples in cubic), tlist=cubic[0][0]))
else:
parts.extend(qutip.coefficient(samples, tlist=grid) for grid, samples in cubic)
if linear:
union = np.unique(np.concatenate([grid for grid, _ in linear]))
total = np.zeros(union.shape, dtype=complex)
for grid, samples in linear:
nonzero = np.flatnonzero(samples)
if nonzero.size == 0:
continue
# The interpolant vanishes beyond the nodes bracketing its nonzero samples.
lo = np.searchsorted(union, grid[max(nonzero[0] - 1, 0)])
hi = np.searchsorted(union, grid[min(nonzero[-1] + 1, grid.size - 1)], side="right")
nodes = union[lo:hi]
total[lo:hi] += np.interp(nodes, grid, samples.real) + 1j * np.interp(nodes, grid, samples.imag)
parts.append(qutip.coefficient(total, tlist=union, order=_WINDOWED_COEFFICIENT_ORDER))
total = parts[0]
for part in parts[1:]:
total = total + part
return total
def _qobj_key(op: Qobj) -> tuple:
"""Exact content key of an operator; equal operators stored differently may get different keys."""
payload: tuple[bytes, ...]
if isinstance(op.data, qutip.data.CSR):
matrix = op.data_as("csr_matrix")
payload = (matrix.indptr.tobytes(), matrix.indices.tobytes(), matrix.data.tobytes())
else:
payload = (np.ascontiguousarray(op.full()).tobytes(),)
return (repr(op.dims), type(op.data).__name__, *payload)
def _assemble_qobjevo(static_rhs: Qobj | None, op_signal_pairs: Any, sample_tlist: Any) -> qutip.QobjEvo:
"""Combine an optional static operator and dynamic entries into a QobjEvo.
Carrier bands acting through equal operators at one concrete frequency
share a coefficient, ``(Σ_k envelope_k(t))·exp(i·freq·t)``, so a solver
step applies each operator once per carrier frequency however many
pulses and crosstalk paths drive it.
"""
terms: list[Any] = [] if static_rhs is None else [static_rhs]
groups: dict[tuple, tuple[Qobj, Any, list[Any]]] = {}
for op, signal in op_signal_pairs:
QuTiPBackend._validate_input(signal)
key = _qobj_key(op)
for band in decompose_carrier_bands(signal):
freq = maybe_concrete_scalar(band.freq)
groups.setdefault((key, id(band) if freq is None else freq), (op, band.freq, []))[2].append(band.envelope)
for op, freq, envelopes in groups.values():
coefficient = _summed_envelope_coefficient(envelopes, sample_tlist)
if maybe_concrete_scalar(freq) != 0.0:
coefficient = coefficient * _carrier_coefficient(freq)
terms.append([op, coefficient])
return qutip.QobjEvo(terms)
def _canonical_key(op: Qobj) -> tuple:
"""Content key of an operator, equal for equal matrices however they are stored."""
matrix = (op.data_as("csr_matrix") if isinstance(op.data, qutip.data.CSR) else sparse.csr_matrix(op.full())).copy()
matrix.sum_duplicates()
matrix.eliminate_zeros()
return (repr(op.dims), matrix.indptr.astype(np.int64).tobytes(), matrix.indices.astype(np.int64).tobytes(),
matrix.data.tobytes())
# Times at which a Hamiltonian's paired coefficients must be conjugate before
# its terms are lifted as Hermitian.
_HERMITIAN_CHECK_TIMES = 129
def _is_hermitian_sum(terms: Sequence[list], tlist: Any) -> bool:
"""Whether ``Σ_k c_k(t)·A_k`` is Hermitian: each operator's adjoint carries the conjugate coefficient."""
classes: dict[tuple, tuple[Qobj, list[Any]]] = {}
for op, coefficient in terms:
classes.setdefault(_canonical_key(op), (op, []))[1].append(coefficient)
span = np.asarray(tlist, dtype=float)
times = np.linspace(span[0], span[-1], _HERMITIAN_CHECK_TIMES)
sums = {key: np.array([sum(c(t) for c in coefficients) for t in times])
for key, (_, coefficients) in classes.items()}
for key, (op, _) in classes.items():
partner = sums.get(_canonical_key(op.dag()))
values = sums[key]
if partner is None or not np.allclose(partner, values.conj(), rtol=1e-12,
atol=1e-12 * np.abs(values).max(initial=0.0)):
return False
return True
def _lindblad_generator(hamiltonian: Any, collapse_ops: Sequence[Any], tlist: Any) -> tuple[Any, Qobj] | None:
"""Lift a Hermitian time-dependent Hamiltonian and its dissipators with one part per coefficient.
QuTiP lifts ``ρH†`` separately, so a term ``c(t)·A`` becomes ``spre(A)``
with ``c`` and ``spost(A†)`` with ``c̄``, and the dissipators join as a
second constant part. For a Hermitian ``H(t)``, ``ρH† = ρH``: each term
lifts to the single part ``-i(spre(A) - spost(A))`` and the static
Hamiltonian shares one constant part with every dissipator, halving the
time-dependent sparse products per step. A quchip Hamiltonian is
Hermitian because every drive band carries its Hermitian partner; the
pairing is checked on the operators and their coefficients.
Returns the time-dependent generator and the constant part, or ``None``
to leave the input to QuTiP's construction.
"""
if not isinstance(hamiltonian, qutip.QobjEvo) or not all(isinstance(op, Qobj) for op in collapse_ops):
return None
static, dynamic = [], []
for part in hamiltonian.to_list():
if isinstance(part, Qobj):
static.append(part)
elif isinstance(part, list) and len(part) == 2 and isinstance(part[0], Qobj) and callable(part[1]):
dynamic.append(part)
else:
return None
if not dynamic or not (static or collapse_ops) or not _is_hermitian_sum(dynamic, tlist):
return None
constant = qutip.liouvillian(sum(static[1:], start=static[0]) if static else None, list(collapse_ops))
generator = qutip.QobjEvo([[-1j * (qutip.spre(op) - qutip.spost(op)), coefficient] for op, coefficient in dynamic])
return generator, constant
# Integrators that only apply the right-hand side to their state, so they can integrate a packed one.
_PACKED_STATE_METHODS = frozenset({"adams", "bdf", "lsoda", "dop853", "vern7", "vern9", "tsit5"})
# The packed right-hand side runs in Python; below this Hilbert dimension its call
# overhead outweighs the halved sparse and vector work (break-even near 40).
_PACKED_STATE_MIN_DIMENSION = 48
# SciPy releases the GIL in sparse products, so a packed product with at least
# this many stored entries splits its rows over threads; a smaller one finishes
# before the hand-off pays.
_THREADED_PRODUCT_MIN_NNZ = 200_000
# Sparse products are bound by memory bandwidth and stop gaining near four threads.
_MAX_PRODUCT_THREADS = 4
# Scaling the inputs by the coefficients joins the threads when it writes at least
# this many entries; a shorter pass finishes before a second hand-off pays.
_THREADED_SCALING_MIN_ENTRIES = 500_000
# Thread pools by (process id, size): a forked child starts its own pool.
_product_pools: dict[tuple[int, int], ThreadPoolExecutor] = {}
def _thread_setting(name: str) -> int | None:
value = os.environ.get(name, "").strip()
return int(value) if value.isdigit() and int(value) > 0 else None
def _product_threads() -> int:
"""Threads for a packed master-equation product.
``QUCHIP_NUM_THREADS`` sets the count. Otherwise a child process, such as
a sweep worker, uses one thread because its siblings share the machine;
``OMP_NUM_THREADS`` applies next, and the default is up to four of the
CPUs available to the process.
"""
explicit = _thread_setting("QUCHIP_NUM_THREADS")
if explicit is not None:
return explicit
if multiprocessing.parent_process() is not None:
return 1
available = getattr(os, "process_cpu_count", os.cpu_count)() or 1
return _thread_setting("OMP_NUM_THREADS") or max(1, min(_MAX_PRODUCT_THREADS, available))
def _row_blocks(matrix: sparse.csr_matrix, count: int) -> list[tuple[int, int, sparse.csr_matrix]]:
"""Contiguous row blocks of a CSR matrix holding about equal numbers of stored entries."""
if count <= 1:
return [(0, matrix.shape[0], matrix)]
bounds = np.searchsorted(matrix.indptr, np.linspace(0, matrix.nnz, count + 1)).clip(0, matrix.shape[0])
bounds[0], bounds[-1] = 0, matrix.shape[0]
return [(int(lo), int(hi), matrix[lo:hi]) for lo, hi in zip(bounds[:-1], bounds[1:]) if hi > lo]
def _share(pool: ThreadPoolExecutor, task: Callable[[Any], None], items: Sequence[Any]) -> None:
"""Run ``task`` over ``items``: the first on this thread, the rest on ``pool``."""
pending = [pool.submit(task, item) for item in items[1:]]
task(items[0])
for future in pending:
future.result()
def _product_pool(threads: int) -> ThreadPoolExecutor:
key = (os.getpid(), threads)
pool = _product_pools.get(key)
if pool is None:
pool = _product_pools[key] = ThreadPoolExecutor(threads, thread_name_prefix="quchip-product")
return pool
class _HermitianPacking:
"""Index maps between a column-stacked density matrix and its packed upper triangle."""
def __init__(self, dimension: int) -> None:
rows, cols = np.triu_indices(dimension)
order = np.lexsort((rows, cols))
rows, cols = rows[order], cols[order]
self.dimension, self.size = dimension, rows.size
self.upper = rows + cols * dimension
self.index = np.full((dimension, dimension), -1, dtype=np.int64)
self.index[rows, cols] = np.arange(rows.size)
lower_rows, lower_cols = np.tril_indices(dimension, -1)
self.lower = lower_rows + lower_cols * dimension
self.mirror = self.index[lower_cols, lower_rows]
def split(self, superoperator: Qobj) -> list[sparse.csr_matrix]:
"""Rows (i ≤ j) of a superoperator as its parts acting on the packed ``x`` and on ``conj(x)``."""
data = superoperator.data
matrix = data.as_scipy() if hasattr(data, "as_scipy") else sparse.csr_matrix(data.to_array())
rows = sparse.csr_matrix(matrix)[self.upper].tocoo()
i, j = rows.col % self.dimension, rows.col // self.dimension
upper = i <= j
shape = (self.size, self.size)
return [
sparse.csr_matrix((rows.data[upper], (rows.row[upper], self.index[i[upper], j[upper]])), shape=shape),
sparse.csr_matrix((rows.data[~upper], (rows.row[~upper], self.index[j[~upper], i[~upper]])), shape=shape),
]
def pack(self, vector: np.ndarray) -> np.ndarray:
return vector[self.upper]
def unpack(self, packed: np.ndarray) -> np.ndarray:
vector = np.empty(self.dimension**2, dtype=complex)
vector[self.upper] = packed
vector[self.lower] = np.conj(packed[self.mirror])
return vector
class _PackedLindbladian(qutip.QobjEvo):
"""Lindblad generator acting on the packed upper triangle of a Hermitian density matrix.
The rows (i ≤ j) of ``L·vec(ρ)`` read the lower triangle of ``ρ`` as the
conjugate of the upper one, so each part acts as ``A·x + B·conj(x)`` on the
packed state ``x``, and one stacked sparse product applies every part. A
large product splits into row blocks on threads (see ``_product_threads``);
each row sums in the same order, so the result does not depend on the split.
"""
def __init__(self, constant: Qobj, dynamic: Sequence[list], packing: _HermitianPacking) -> None:
super().__init__(qutip.qeye(packing.size))
superoperators = (constant, *(op for op, _ in dynamic))
stacked = sparse.hstack([block for op in superoperators for block in packing.split(op)], format="csr")
threads = _product_threads() if stacked.nnz >= _THREADED_PRODUCT_MIN_NNZ else 1
self._blocks = _row_blocks(stacked, threads)
# The calling thread takes the first share of each step while the pool runs the rest.
self._pool = _product_pool(len(self._blocks) - 1) if len(self._blocks) > 1 else None
bounds = np.linspace(0, 2 * packing.size, len(self._blocks) + 1).astype(int)
threaded_scaling = 2 * packing.size * len(dynamic) >= _THREADED_SCALING_MIN_ENTRIES
self._chunks = list(zip(bounds[:-1], bounds[1:])) if threaded_scaling else [(0, 2 * packing.size)]
self._coefficients = [coefficient for _, coefficient in dynamic]
self._size = packing.size
self._inputs = np.empty(2 * len(superoperators) * packing.size, dtype=complex)
def _scale_inputs(self, factors: list[complex], chunk: tuple[int, int]) -> None:
"""Write ``c_k·[x, conj(x)]`` over one chunk of every coefficient's input block."""
low, high = chunk
span = 2 * self._size
for k, factor in enumerate(factors, start=1):
np.multiply(self._inputs[low:high], factor, out=self._inputs[k * span + low:k * span + high])
def _apply(self, result: np.ndarray, added: np.ndarray | None, scale: complex,
block: tuple[int, int, sparse.csr_matrix]) -> None:
low, high, rows = block
product = rows @ self._inputs
if scale != 1:
product *= scale
if added is not None:
product += added[low:high]
result[low:high] = product
def matmul_data(self, t: Any, state: Any, out: Any = None, scale: complex = 1) -> Any:
size, inputs = self._size, self._inputs
x = (state.as_ndarray() if isinstance(state, qutip.data.Dense) else state.to_array()).reshape(-1)
inputs[:size] = x
np.conjugate(x, out=inputs[size:2 * size])
factors = [coefficient(t) for coefficient in self._coefficients]
added = None
if out is not None:
added = (out.as_ndarray() if isinstance(out, qutip.data.Dense) else out.to_array()).reshape(-1)
result = np.empty(size, dtype=complex)
if self._pool is None:
self._scale_inputs(factors, (0, 2 * size))
self._apply(result, added, scale, self._blocks[0])
else:
_share(self._pool, partial(self._scale_inputs, factors), self._chunks)
_share(self._pool, partial(self._apply, result, added, scale), self._blocks)
return qutip.data.Dense(result.reshape(-1, 1), copy=False)
class _HermitianMESolver(MESolver):
"""``MESolver`` integrating the packed upper triangle of a Hermitian density matrix.
A Hermiticity-preserving generator keeps ``ρ`` Hermitian, so the strictly
lower triangle carries no information: the integrator advances half the
entries and applies half the sparse rows. Saved states are unpacked, so
results, expectation values and options behave as for ``MESolver``.
"""
def __init__(self, generator: Any, constant: Qobj, *, options: dict[str, Any]) -> None:
dynamic = generator.to_list()
self._packed = None
super().__init__(generator, [constant], options=options)
self._packing = _HermitianPacking(math.isqrt(constant.shape[0]))
self._packed = _PackedLindbladian(constant, dynamic, self._packing)
self._integrator = self._get_integrator()
def _get_integrator(self) -> Any:
if self._packed is None:
return super()._get_integrator()
return self.avail_integrators()[self._options["method"]](self._packed, self.options)
def _prepare_state(self, state: Qobj) -> Any:
vector = super()._prepare_state(state).to_array().reshape(-1)
return qutip.data.Dense(self._packing.pack(vector).reshape(-1, 1), copy=False)
def _restore_state(self, data: Any, *, copy: bool = True) -> Qobj:
vector = self._packing.unpack(data.to_array().reshape(-1))
return super()._restore_state(qutip.data.Dense(vector.reshape(-1, 1), copy=False), copy=False)
def _packs_hermitian_state(state: Any, options: dict[str, Any]) -> bool:
"""Whether a lifted master equation from ``state`` may integrate a packed Hermitian state."""
method = options.get("method", MESolver.solver_options["method"])
if not isinstance(method, str) or method not in _PACKED_STATE_METHODS:
return False
return state.shape[0] >= _PACKED_STATE_MIN_DIMENSION and (state.isket or (state.isoper and state.isherm))
# A reused stationary state may leave a residual of at most this many machine
# epsilons of the generator's scale, as a direct solve does.
_STATIONARY_REUSE_EPS = 16
def _annihilates(liouvillian: sparse.csr_matrix, state: Any) -> bool:
"""Whether a superoperator maps ``state`` to zero to round-off."""
size = math.isqrt(liouvillian.shape[0])
if not isinstance(state, Qobj) or not state.isoper or state.shape != (size, size):
return False
vector = np.asarray(qutip.operator_to_vector(state).full(), dtype=complex).reshape(-1)
scale = float(abs(liouvillian).sum(axis=0).max()) * float(np.linalg.norm(vector))
return bool(np.linalg.norm(liouvillian @ vector) <= _STATIONARY_REUSE_EPS * np.finfo(float).eps * scale)
def _require_lu_memory(matrix: Any) -> None:
"""Raise before SuperLU factorizes a stationary generator whose factors cannot fit in memory."""
size = matrix.shape[0]
# The estimate never exceeds a dense matrix, so the pattern needs no analysis when one fits.
if fits_memory(dense_bytes(size, size)):
return
require_memory(
sparse_lu_bytes(matrix),
task=f"Factorizing the {size} × {size} stationary generator",
remedy="Reduce the device cutoffs. This estimate is low; the factors usually need more memory.",
)
def _direct_steady_state(liouvillian: sparse.csr_matrix, dims: Sequence[int]) -> Qobj | None:
"""Solve as ``qutip.steadystate``'s default direct method does, without its option scope.
QuTiP adds ``w·vec(1)ᵀ`` to the generator's first row, with ``w`` the mean
magnitude of its non-negligible entries, solves for ``w·e₀`` and keeps the
Hermitian part. Its solve runs inside an option scope whose entry and exit
rebuild every data-layer dispatcher, which takes longer than the solve for
small systems. Returns ``None`` for a generator without entries.
"""
atol = qutip.settings.core["atol"]
data = liouvillian.data
significant = (np.abs(data.real) > atol) | (np.abs(data.imag) > atol)
if not significant.any():
return None
weight = float(np.abs(data[significant]).mean())
size = liouvillian.shape[0]
n = math.isqrt(size)
trace = sparse.csr_matrix(
(np.full(n, weight, dtype=complex), (np.zeros(n, dtype=int), np.arange(0, size, n + 1))), shape=(size, size),
)
target = np.zeros(size, dtype=complex)
target[0] = weight
constrained = (liouvillian + trace).tocsr()
_require_lu_memory(constrained)
vector = sparse.linalg.spsolve(constrained, target)
rho = vector.reshape(n, n, order="F")
return Qobj(0.5 * (rho + rho.conj().T), dims=[list(dims), list(dims)], isherm=True)
# A loky reusable executor respawns its worker pool after a short idle window
# (10 s by default), and that respawn costs ~3 s on the next sweep. Sweeps in an
# interactive session arrive minutes apart, so the pool is kept warm for an hour.
_POOL_IDLE_TIMEOUT_S = 3600
# Each loky worker imports quchip, QuTiP and JAX, which takes about 190 MB
# before its first problem.
_WORKER_BYTES = 190 * 10**6
def _warmup_noop(_idx: Any = None) -> None:
"""Force loky fork/import out of timed regions (top-level no-op).
Must be a module-level function (loky pickles tasks by reference; lambdas
and closures are not picklable).
"""
return None
[docs]
class QuTiPBackend(Backend):
"""Concrete backend that uses QuTiP. ``Operator`` = ``State`` = ``qutip.Qobj``.
Example
-------
>>> from quchip.backend.qutip import QuTiPBackend
>>> backend = QuTiPBackend()
>>> a = backend.destroy(3)
>>> float((a.dag() * a).diag()[2].real) # doctest: +SKIP
2.0
"""
# Batches smaller than this run sequentially in-process: the loky pool's
# fork/import/IPC overhead dominates a handful of fast QuTiP solves.
_PARALLEL_MIN_BATCH = 8
# Worker count of the reusable loky pool that this process started, or zero.
_pool_workers = 0
def __init__(self) -> None:
# Per-(kind, dim) memo of the Fock-space operator factories. These are
# rebuilt many times during Hamiltonian assembly yet depend only on the
# truncation ``n`` (always a concrete int). Kept strictly QuTiP-local:
# a *shared* operator cache would leak ``DynamicJaxprTracer`` operators
# when poisoned inside a ``jax.jit`` trace and break a later
# ``grad``/``vmap`` — QuTiP factories always return pure ``Qobj``, so
# this cache is provably tracer-free.
self._op_cache: dict[tuple[str, int], Operator] = {}
# ------------------------------------------------------------------
# Array / scalar surface — QuTiP natively returns Qobj-flavoured answers
# ------------------------------------------------------------------
@property
def array_module(self) -> Any:
return np
@staticmethod
def _validate_input(value: Any) -> None:
"""Reject traced values when lowering to QuTiP's concrete representation."""
if contains_tracer(value):
raise ValueError("JAX tracing requires backend='dynamiqs'. This calculation uses 'qutip'.")
[docs]
def to_array(self, op: Operator) -> Any:
if isinstance(op, Qobj):
return np.asarray(op.full(), dtype=complex)
if hasattr(op, "to_jax"):
return np.asarray(op.to_jax(), dtype=complex)
return np.asarray(op, dtype=complex)
[docs]
def overlap(self, a: State, b: State) -> complex:
value = a.dag() @ b
if isinstance(value, Qobj):
return complex(value.full()[0, 0])
return complex(value)
[docs]
def norm(self, state_or_op: State | Operator) -> float:
r"""Return the native QuTiP norm.
Parameters
----------
state_or_op : qutip.Qobj
Ket or bra for the Euclidean norm, or matrix for the trace norm.
Returns
-------
float
``Qobj.norm()`` with its default norm selection.
"""
return float(state_or_op.norm())
[docs]
def trace(self, op: Operator) -> complex:
return complex(op.tr())
# ------------------------------------------------------------------
# Operator / state factories — defer to QuTiP's own constructors
# ------------------------------------------------------------------
def _cached_op(self, kind: str, n: int, factory: Callable[[int], Operator]) -> Operator:
key = (kind, n)
cached = self._op_cache.get(key)
if cached is None:
cached = factory(n)
self._op_cache[key] = cached
return cached
[docs]
def destroy(self, n: int) -> Operator:
return self._cached_op("destroy", n, qutip.destroy)
[docs]
def create(self, n: int) -> Operator:
return self._cached_op("create", n, qutip.create)
[docs]
def number(self, n: int) -> Operator:
return self._cached_op("number", n, qutip.num)
[docs]
def identity(self, n: int) -> Operator:
return self._cached_op("identity", n, qutip.qeye)
[docs]
def from_array(self, data: Any, dims: list[list[int]] | None = None) -> Operator:
self._validate_input(data)
if isinstance(data, Qobj):
return data if dims is None or data.dims == dims else Qobj(data.data, dims=dims)
if hasattr(data, "to_jax"):
data = np.asarray(data.to_jax(), dtype=complex)
return Qobj(data, dims=dims)
[docs]
def compact_operator(self, rows: Any, columns: Any, values: Any, size: int) -> Operator:
from quchip.engine.ir import CanonicalOperator
matrix = sparse.csr_matrix((np.asarray(values, dtype=complex), (rows, columns)), shape=(size, size))
matrix.eliminate_zeros()
return self.from_canonical_operator(CanonicalOperator.from_csr(
matrix.data, matrix.indices, matrix.indptr, shape=matrix.shape, dims=(size,), basis="fock",
subsystem_labels=("0",),
))
[docs]
def compact_operators_fit(self, size: int, count: int) -> bool:
# CSR operators keep only their entries, which the generator's entries bound.
return True
[docs]
def linear_combination(self, terms: Sequence[tuple[Any, Operator]]) -> Operator:
self._validate_input(terms)
return super().linear_combination(terms)
def _collapse_operators(self, engine_result: Any) -> list[Operator]:
self._validate_input([term.rate for term in engine_result.collapse_terms])
return super()._collapse_operators(engine_result)
[docs]
def to_canonical_operator(self, op: Operator) -> Any:
from quchip.engine.ir import CanonicalOperator
if isinstance(op, Qobj):
dims = tuple(op.dims[0])
labels = tuple(str(i) for i in range(len(dims)))
if type(op.data).__name__ != "Dense":
csr = op.to("CSR").data_as("csr_matrix")
return CanonicalOperator.from_csr(
csr.data, csr.indices, csr.indptr,
shape=op.shape, dims=dims, basis="fock", subsystem_labels=labels,
)
return CanonicalOperator.from_dense(
np.asarray(op.full(), dtype=complex),
dims=dims, basis="fock", subsystem_labels=labels,
)
arr = np.asarray(op, dtype=complex)
return CanonicalOperator.from_dense(
arr, dims=(arr.shape[0],), basis="fock", subsystem_labels=("0",),
)
[docs]
def from_canonical_operator(self, canonical: Any) -> Operator:
return self._canonical_to_qobj(canonical)
[docs]
def coerce_operator(self, op: Operator) -> Operator:
if isinstance(op, Qobj):
return op
return self.from_array(np.asarray(op, dtype=complex))
[docs]
def dag(self, op: Operator) -> Operator:
return self.coerce_operator(op).dag()
[docs]
def eigenenergies(self, op: Operator) -> Any:
return op.eigenenergies()
[docs]
def eigensystem_data(self, op: Operator) -> EigensystemData:
if isinstance(op, Qobj):
# Keep ``Qobj.eigenstates()`` verbatim: it preserves the exact
# degenerate-subspace basis that ``operator_in_dressed_basis``
# relies on (np.linalg.eigh reorders degenerate eigenvectors).
# The kets are produced regardless, so prime the cache directly.
evals, states = op.eigenstates()
states = list(states)
evecs = np.column_stack(
[np.asarray(state.full(), dtype=complex).reshape(-1) for state in states]
)
return EigensystemData(
eigenvalues=evals,
eigenvector_matrix=evecs,
_states_cache=states,
)
# Dense (non-Qobj) inputs use the protocol default verbatim: it densifies
# via this backend's ``to_array``/``from_array``, so the kets are the same
# ``Qobj`` columns (dims inferred to ``[[n], [1]]``) as a hand-rolled branch.
return super().eigensystem_data(op)
[docs]
def expect(self, op: Operator, state: State) -> complex:
return qutip.expect(op, state)
[docs]
def expect_over_time(self, op: Operator, stacked_states: Any) -> Any:
if not (isinstance(op, Qobj) and isinstance(op.data, qutip.data.CSR)):
return super().expect_over_time(op, stacked_states)
# A CSR operator acts on the stacked states without a dense copy.
matrix = op.data_as("csr_matrix")
states = np.asarray(self.to_array(stacked_states), dtype=complex)
if self._is_ket_stack(states):
psi = states[..., 0]
return np.einsum("ti,ti->t", psi.conj(), (matrix @ psi.T).T)
entries = matrix.tocoo()
return np.einsum("k,tk->t", entries.data, states[:, entries.col, entries.row])
[docs]
def ptrace(self, state: State, keep: int | list[int], dims: list[int]) -> State:
# QuTiP's Qobj.ptrace needs the correct composite dims. Rebuild when the
# incoming state carries flat dims (as when assembled outside tensor).
if isinstance(state, Qobj):
if state.dims[0] != dims and len(dims) > 1:
new_dims = [dims, [1] * len(dims)] if state.isket else [dims, dims]
state = Qobj(state.data, dims=new_dims)
return state.ptrace(keep)
[docs]
def permute_state(self, state: State, dims: Sequence[int], order: Sequence[int]) -> State:
if not isinstance(state, Qobj):
return super().permute_state(state, dims, order)
dims = list(dims)
if state.dims[0] != dims and len(dims) > 1:
new_dims = [dims, [1] * len(dims)] if state.isket else [dims, dims]
state = Qobj(state.data, dims=new_dims)
return state.permute(list(order))
[docs]
def tensor(self, *operators: Operator) -> Operator:
return qutip.tensor([self.coerce_operator(op) for op in operators])
# ------------------------------------------------------------------
# Embedding helpers
# ------------------------------------------------------------------
[docs]
def embed_two_body(
self,
op_ab: Operator,
index_a: int,
index_b: int,
dims: Sequence[int],
) -> Operator:
validate_two_body_indices(index_a, index_b, dims)
local_dims = [dims[index_a], dims[index_b]]
expected_dim = local_dims[0] * local_dims[1]
if op_ab.shape[0] != expected_dim:
raise ValueError(
f"Two-body operator dimension {op_ab.shape[0]} does not match "
f"dims[{index_a}]*dims[{index_b}] = {expected_dim}"
)
local = op_ab if op_ab.dims == [local_dims, local_dims] else Qobj(op_ab.data, dims=[local_dims, local_dims])
if index_b == index_a + 1:
# Keep native tensor layout selection for adjacent, ordered targets.
return qutip.tensor([local if index == index_a else qutip.qeye(dim)
for index, dim in enumerate(dims) if index != index_b])
return qutip.expand_operator(local.to("CSR"), list(dims), targets=[int(index_a), int(index_b)])
# ------------------------------------------------------------------
# State factories
# ------------------------------------------------------------------
[docs]
def basis(self, n: int, k: int) -> State:
return qutip.basis(n, k)
[docs]
def tensor_states(self, *states: State) -> State:
return qutip.tensor(list(states))
[docs]
def coherent(self, n: int, alpha: complex) -> State:
return qutip.coherent(n, alpha)
[docs]
def state_to_dm(self, state: State) -> State:
state = self.coerce_state(state)
return state if not state.isket else qutip.ket2dm(state)
[docs]
def is_ket(self, state: State) -> bool:
return state.isket if isinstance(state, Qobj) else super().is_ket(state)
[docs]
def is_native_state(self, state: Any) -> bool:
return isinstance(state, Qobj)
# ------------------------------------------------------------------
# Solver options / heuristics
# ------------------------------------------------------------------
[docs]
def resolve_solver_options(
self,
options: dict[str, Any],
*,
metadata: dict[str, Any],
tlist: Any,
) -> dict[str, Any]:
r"""Resolve QuTiP integration options without changing the input mapping.
Parameters
----------
options : dict
Native QuTiP settings, e.g. ``method``, ``atol``, ``rtol``, ``nsteps`` (step
ceiling), and ``max_step`` (ns). Unspecified step controls use engine hints, and
explicit settings take priority. For method-specific keys, see
`QuTiP solver options <https://qutip.readthedocs.io/en/stable/apidoc/solver.html>`_.
Select the saved-state storage via the simulation's ``states`` argument.
metadata : dict
Lowering hints, including ordinary-GHz spectral/carrier bounds.
tlist : array_like
Save-time grid in ns, used to estimate integration budgets.
Returns
-------
dict
Copied options, with the missing integration defaults filled.
"""
resolved = dict(options)
if "nsteps" not in resolved:
default = self._default_nsteps(metadata, tlist)
if default is not None:
resolved["nsteps"] = default
if "max_step" not in resolved:
max_step_ns = maybe_concrete_scalar(metadata.get("max_step_ns"))
if max_step_ns is not None and max_step_ns > 0 and np.isfinite(max_step_ns):
resolved["max_step"] = max_step_ns
max_step = maybe_concrete_scalar(resolved.get("max_step"))
span = maybe_concrete_scalar(tlist[-1] - tlist[0])
if ("nsteps" not in options and max_step is not None and max_step > 0
and span is not None and np.isfinite(span / max_step)):
resolved["nsteps"] = max(resolved.get("nsteps", 200_000), 2 * math.ceil(span / max_step))
return resolved
def _resolve_automatic_solver_options(
self,
options: dict[str, Any],
*,
user_options: dict[str, Any],
engine_result: Any,
solver_name: str,
tlist: Any,
) -> dict[str, Any]:
"""Select QuTiP's diagonal propagator for small constant generators."""
_ = tlist
resolved = dict(options)
adaptive_options = {"atol", "rtol", "nsteps", "max_step"}
explicit_method = user_options.get("method")
if explicit_method == "diag":
incompatible = adaptive_options & user_options.keys()
if incompatible:
raise ValueError(f"QuTiP method='diag' does not support options {sorted(incompatible)}.")
for key in adaptive_options:
resolved.pop(key, None)
return resolved
# Cascade-generated terms can leave a degenerate Liouvillian non-diagonalizable,
# so this reads slh.H (the resolved model), not the drive-augmented static terms.
if (
explicit_method is not None
or bool(adaptive_options & user_options.keys())
or engine_result.dynamic_terms
or engine_result.slh.has_network_hamiltonian
):
return resolved
dimension = math.prod(engine_result.dims)
generator_dim = dimension if solver_name == "sesolve" else dimension**2
limit = _MAX_STATIC_HILBERT_DIM if solver_name == "sesolve" else _MAX_STATIC_LIOUVILLIAN_DIM
if generator_dim > limit:
return resolved
for key in adaptive_options:
resolved.pop(key, None)
resolved["method"] = "diag"
return resolved
_default_nsteps = staticmethod(default_solver_steps)
[docs]
def coerce_state(self, state: State, dims: tuple[int, ...] | None = None) -> State:
if isinstance(state, Qobj):
return state
arr = np.asarray(state)
if arr.ndim == 1:
arr = arr[:, None]
subsystem = [int(d) for d in dims] if dims else [arr.shape[0]]
qdims = [subsystem, subsystem] if arr.shape[1] == arr.shape[0] else [subsystem, [1] * len(subsystem)]
return Qobj(arr, dims=qdims)
# ------------------------------------------------------------------
# Single-problem solver dispatch
# ------------------------------------------------------------------
[docs]
def solve_problem(self, problem: Any) -> SolverResult:
if not problem.stochastic:
return super().solve_problem(problem)
if problem.solver not in ("mcsolve", "ssesolve", "smesolve"):
raise ValueError(f"QuTiP does not provide {problem.solver!r}.")
options = dict(problem.options)
if problem.states is not None:
if {"store_states", "store_final_state"} & options.keys():
raise ValueError("Conflicting states and native storage options.")
options.update(store_states=problem.states == "all", store_final_state=problem.states == "final")
rhs = self.prepare_hamiltonian(problem.engine_result, problem.tlist).rhs
from copy import deepcopy
kwargs = dict(e_ops=problem.e_ops, options=options, **problem.run_args)
if "seeds" in kwargs:
kwargs["seeds"] = deepcopy(kwargs["seeds"])
if problem.solver == "mcsolve":
operators = self._collapse_operators(problem.engine_result)
if not operators:
raise ValueError(
"mcsolve requires a declared jump channel; use a deterministic solver for closed evolution."
)
kwargs["c_ops"] = operators
else:
from quchip.engine.monitoring import monitored_operators
loss, monitored, etas = monitored_operators(problem)
kwargs["sc_ops"] = [np.sqrt(eta) * op for eta, op in zip(etas, monitored)]
if problem.solver == "smesolve":
kwargs["c_ops"] = loss + [np.sqrt(1 - eta) * op for eta, op in zip(etas, monitored)]
_require_superoperator_memory(
rhs, [*kwargs["c_ops"], *kwargs["sc_ops"]], task="QuTiP smesolve's D²×D² Liouvillian",
remedy="Reduce the device cutoffs.",
)
native = getattr(qutip, problem.solver)(
rhs, self.coerce_state(problem.initial_state, dims=problem.engine_result.dims), problem.tlist, **kwargs)
return SolverResult(times=problem.tlist, solver=problem.solver, native=native)
[docs]
def trajectory_view(
self, native: Any, problem: Any, *, index: int | None, states: bool, deterministic: bool,
) -> tuple[Any, Any, Any]:
if index is None:
values = native.average_expect
history = native.average_states if states else None
final = native.average_final_state if states else None
else:
runs = native.deterministic_trajectories if deterministic else native.trajectories
if not runs:
raise RuntimeError("Individual runs require native keep_runs_results=True.")
run = runs[index]
values, history, final = run.expect, run.states if states else None, run.final_state if states else None
return values, history or None, final
[docs]
def trajectory_records(self, native: Any, problem: Any) -> tuple[Any, str]:
sampling = problem.options.get("store_measurement", "")
if problem.solver not in ("ssesolve", "smesolve") or not sampling:
raise ValueError("Records need ssesolve or smesolve with the native option store_measurement "
"'start', 'middle' or 'end'.")
return np.asarray(native.measurement, dtype=float), "end" if sampling is True else sampling
[docs]
def trajectory_clicks(self, native: Any, problem: Any) -> tuple[list[Any], Any, Any, Any]:
if problem.solver != "mcsolve":
raise ValueError("Clicks need a jump solver: mcsolve.")
events = [(np.asarray(times, dtype=float), np.asarray(which, dtype=int))
for times, which in zip(native.col_times, native.col_which)]
weights = np.asarray(native.runs_weights, dtype=float)
return events, weights, float(sum(native.deterministic_weights)), np.zeros(len(events), dtype=bool)
[docs]
def trajectory_run_expect(self, native: Any, problem: Any) -> list[Any]:
if problem.e_ops and not native.runs_expect:
raise RuntimeError("Per-run values require native keep_runs_results=True.")
return list(native.runs_expect)
[docs]
def sesolve(
self,
H: Any,
psi0: State,
tlist: Any,
e_ops: list[Operator] | None = None,
options: dict[str, Any] | None = None,
) -> SolverResult:
runner = SESolver(self._coerce_solver_rhs(H), options=self._runner_options(options))
result = runner.run(psi0, tlist, e_ops=e_ops)
return self._wrap_result(result, solver="sesolve", extra_stats=self._solver_stats(runner))
[docs]
def mesolve(
self,
H: Any,
rho0: State,
tlist: Any,
c_ops: list[Operator] | None = None,
e_ops: list[Operator] | None = None,
options: dict[str, Any] | None = None,
) -> SolverResult:
rhs = self._coerce_solver_rhs(H)
_require_superoperator_memory(
rhs, c_ops or [], task="QuTiP mesolve's D²×D² Liouvillian",
remedy="Use backend='dynamiqs', whose mesolve applies the Lindblad generator without "
"forming it, or reduce the device cutoffs.",
)
options = self._runner_options(options)
lifted = None if options.get("matrix_form") else _lindblad_generator(rhs, c_ops or [], tlist)
if lifted is None:
runner = MESolver(rhs, c_ops, options=options)
else:
# A superoperator collapse term joins the generator as its constant part.
generator, constant = lifted
if _packs_hermitian_state(rho0, options):
runner = _HermitianMESolver(generator, constant, options=options)
else:
runner = MESolver(generator, [constant], options=options)
result = runner.run(rho0, tlist, e_ops=e_ops)
return self._wrap_result(result, solver="mesolve", extra_stats=self._solver_stats(runner))
def _stationary_liouvillian(self, engine_result: Any) -> Qobj:
"""Lower one static engine description to QuTiP's native stationary system."""
if engine_result.dynamic_terms:
raise ValueError("Stationary analysis requires a static resolved Hamiltonian.")
hamiltonian = self.prepare_hamiltonian(engine_result).rhs
collapse_ops = self._collapse_operators(engine_result)
_require_superoperator_memory(
hamiltonian, collapse_ops, task="The D²×D² stationary Liouvillian",
remedy="Reduce the device cutoffs.",
)
return qutip.liouvillian(hamiltonian, collapse_ops)
@staticmethod
def _scipy_liouvillian(liouvillian: Qobj) -> Any:
"""Expose a QuTiP Liouvillian as SciPy CSR without densifying it."""
if hasattr(liouvillian.data, "as_scipy"):
return liouvillian.data.as_scipy().tocsr()
return sparse.csr_matrix(liouvillian.data.to_array())
[docs]
def steadystate(
self, problem: Any, *, prepared: PreparedStationary | None = None, guess: State | None = None,
) -> SteadyStateSolverResult:
r"""Solve a static Lindblad generator with :func:`qutip.steadystate`.
Parameters
----------
problem : SteadyStateProblem
Captured static model, observables, and stationary solver options.
prepared : PreparedStationary or None, default None
Matching prepared generator. ``None`` builds it.
guess : Qobj or None, default None
Stationary state of a related generator. If this generator
annihilates the state to round-off, the method returns it without
solving and the stats record ``guess_reused``.
Returns
-------
SteadyStateSolverResult
Stationary density matrix and convergence diagnostics.
See Also
--------
quchip.backend.protocol.Backend.steadystate
Notes
-----
``problem.options`` accepts native QuTiP stationary-solver keywords, e.g.
``method`` (default ``"direct"``) and ``solver`` (default None). quchip
consumes ``rank_tolerance`` (singular-value cutoff, default automatic) and
``diagnostic_max_dimension`` (default 16). Above that Hilbert dimension, the
nullity is not computed, and ``None`` means unchecked, not a unique state.
"""
liouvillian = self.prepare_stationary(problem.engine_result, prepared=prepared).liouvillian
options = dict(problem.options)
rank_tolerance = options.pop("rank_tolerance", None)
diagnostic_max_dimension = int(options.pop("diagnostic_max_dimension", 16))
if diagnostic_max_dimension < 0:
raise ValueError("diagnostic_max_dimension must be non-negative.")
method = options.pop("method", "direct")
solver = options.pop("solver", None)
sparse_liouvillian = self._scipy_liouvillian(liouvillian)
reused = guess is not None and _annihilates(sparse_liouvillian, guess)
if guess is not None and reused:
state = guess
else:
direct = method == "direct" and solver is None and not options
state = _direct_steady_state(sparse_liouvillian, problem.engine_result.dims) if direct else None
if state is None:
state = qutip.steadystate(liouvillian, method=method, solver=solver, **options)
state_vector = np.asarray(qutip.operator_to_vector(state).full(), dtype=complex).reshape(-1)
residual = float(np.linalg.norm(sparse_liouvillian @ state_vector))
dimension = state.shape[0]
nullity = None
if dimension <= diagnostic_max_dimension:
dense_liouvillian = np.asarray(sparse_liouvillian.toarray(), dtype=complex)
singular_values = np.linalg.svd(dense_liouvillian, compute_uv=False)
if rank_tolerance is None:
scale = singular_values[0] if singular_values.size else 0.0
rank_tolerance = max(dense_liouvillian.shape) * np.finfo(float).eps * scale
nullity = int(np.count_nonzero(singular_values <= rank_tolerance))
expectations = None
if isinstance(problem.e_ops, list):
expectations = [qutip.expect(operator, state) for operator in problem.e_ops]
return SteadyStateSolverResult(
state=state,
expect=expectations,
stats={
"method": method,
"solver": solver,
"uniqueness_checked": nullity is not None,
"diagnostic_max_dimension": diagnostic_max_dimension,
"guess_reused": reused,
},
residual=residual,
nullity=nullity,
_condition_number=(
DeferredValue(partial(self._stationary_condition_number, problem.engine_result))
if dimension <= diagnostic_max_dimension else None
),
)
[docs]
def stationary_bytes(self, engine_result: Any) -> int:
"""Return the estimated bytes of the sparse LU factors of a stationary generator.
The method builds the generator again. The estimate is low, as
:func:`~quchip.utils.memory.sparse_lu_bytes` says.
Parameters
----------
engine_result : EngineResult
Captured static Hamiltonian and collapse channels.
"""
return sparse_lu_bytes(self._scipy_liouvillian(self._stationary_liouvillian(engine_result)))
def _stationary_condition_number(self, engine_result: Any) -> float:
liouvillian = self._scipy_liouvillian(self._stationary_liouvillian(engine_result)).toarray()
return float(stationary_condition_number(liouvillian, math.prod(engine_result.dims), xp=np))
[docs]
def linear_response(self, problem: Any) -> LinearResponseSolverResult:
return linear_response(problem, xp=np)
[docs]
def coherence_response(self, problem: Any) -> CoherenceResponseSolverResult:
return coherence_response(problem, xp=np)
[docs]
def stationary_resolvent(
self,
engine_result: Any,
sources: tuple[tuple[str, Any], ...],
observables: tuple[tuple[str, Any], ...],
frequencies: Any,
*,
prepared: PreparedStationary | None = None,
) -> dict[tuple[str, str], Any]:
liouvillian = self.prepare_stationary(engine_result, prepared=prepared).liouvillian
matrix = self._scipy_liouvillian(liouvillian)
native_sources = [(label, self.from_canonical_operator(operator)) for label, operator in sources]
dims = native_sources[0][1].dims
dimension = math.prod(engine_result.dims)
targets = np.stack(
[
np.asarray(qutip.operator_to_vector(operator).full(), dtype=complex).reshape(-1)
for _, operator in native_sources
],
axis=1,
)
targets[-1, :] = 0.0
size = matrix.shape[0]
keep = sparse.diags([1.0] * (size - 1) + [0.0], format="csr", dtype=complex)
trace_row = sparse.csr_matrix(
(np.ones(dimension, dtype=complex), (np.full(dimension, size - 1), np.arange(0, size, dimension + 1))),
shape=(size, size),
)
base = keep @ matrix + trace_row
# The frequency shift adds only diagonal entries, which every envelope holds.
_require_lu_memory(base)
native_observables = tuple(
(label, self.from_canonical_operator(operator))
for label, operator in observables
)
values: dict[tuple[str, str], list[Any]] = {
(source_label, label): [] for source_label, _ in sources for label, _ in observables
}
for frequency in np.atleast_1d(np.asarray(frequencies, dtype=float)):
constrained = (base + 1j * (2.0 * np.pi) * frequency * keep).tocsc()
solutions = sparse.linalg.splu(constrained).solve(targets)
for column, (source_label, _) in enumerate(native_sources):
response = Qobj(
solutions[:, column].reshape((dimension, dimension), order="F"),
dims=dims,
)
for label, observable in native_observables:
values[(source_label, label)].append((observable * response).tr())
return {key: np.asarray(items) for key, items in values.items()}
[docs]
def stationary_propagate(
self,
engine_result: Any,
initial: Any,
observables: tuple[tuple[str, Any], ...],
times: Any,
*,
prepared: PreparedStationary | None = None,
) -> dict[str, Any]:
liouvillian = self.prepare_stationary(engine_result, prepared=prepared).liouvillian
matrix = self._scipy_liouvillian(liouvillian)
initial_operator = self.from_canonical_operator(initial)
initial_vector = np.asarray(
qutip.operator_to_vector(initial_operator).full(), dtype=complex
).reshape(-1)
dimension = initial_operator.shape[0]
native_observables = tuple(
(label, self.from_canonical_operator(operator))
for label, operator in observables
)
values: dict[str, list[Any]] = {label: [] for label, _ in observables}
for time in np.atleast_1d(np.asarray(times, dtype=float)):
evolved_vector = sparse.linalg.expm_multiply(matrix * time, initial_vector)
evolved = Qobj(
evolved_vector.reshape((dimension, dimension), order="F"),
dims=initial_operator.dims,
)
for label, observable in native_observables:
values[label].append((observable * evolved).tr())
return {label: np.asarray(items) for label, items in values.items()}
@staticmethod
def _runner_options(options: dict[str, Any] | None) -> dict[str, Any]:
"""Trust the already-merged options; only fill defaults when ``None``.
The single option-merge boundary (:meth:`resolve_solver_options`,
reached via :meth:`_merge_options`) has already applied
``_DEFAULT_SOLVE_OPTIONS``, so a supplied dict is forwarded as a defensive copy —
re-merging here would duplicate that work. A bare ``None`` (a direct
solver call without the merge boundary) still gets the store-state
defaults.
"""
if options is None:
return dict(_DEFAULT_SOLVE_OPTIONS)
return dict(options)
# ------------------------------------------------------------------
# Batched solver dispatch (parallel via loky)
# ------------------------------------------------------------------
[docs]
def parallel_solve_problems(
self,
problems: list[Any],
*,
progress: bool = True,
max_workers: int | None = None,
) -> list[SolverResult] | None:
r"""Solve large, structurally heterogeneous problem lists through loky workers.
Parameters
----------
problems
Problems that cannot be merged into one structural batch.
progress
Display solver progress if the backend supports it.
max_workers
Worker processes. ``None`` chooses the count, and ``1`` solves in
this process.
Returns
-------
list[SolverResult] | None
Backend results in input order, or ``None`` to keep the engine's
structural-group dispatch.
See Also
--------
quchip.backend.protocol.Backend.parallel_solve_problems
"""
if len(problems) < self._PARALLEL_MIN_BATCH:
return None
return self._parallel_map(
task=self.solve_problem,
items=problems,
max_workers=max_workers,
point_bytes=max(self._point_bytes(problem) for problem in problems),
progress=progress,
desc="Sweep (independent)",
)
[docs]
def batched_sesolve(
self,
problems: list[dict[str, Any]],
*,
n_jobs: int = -1,
progress: bool = True,
) -> list[SolverResult]:
return self._batched_solve(problems, solver_fn="sesolve", n_jobs=n_jobs, progress=progress)
[docs]
def batched_mesolve(
self,
problems: list[dict[str, Any]],
*,
n_jobs: int = -1,
progress: bool = True,
) -> list[SolverResult]:
return self._batched_solve(problems, solver_fn="mesolve", n_jobs=n_jobs, progress=progress)
def _batched_solve(
self,
problems: list[dict[str, Any]],
*,
solver_fn: str,
n_jobs: int = -1,
progress: bool = True,
) -> list[SolverResult]:
"""Dispatch each problem dict through :meth:`sesolve` / :meth:`mesolve` in parallel."""
solve = getattr(self, solver_fn)
return self._parallel_map(
task=lambda problem: solve(**problem),
items=problems,
max_workers=None if n_jobs in (-1, None) else n_jobs,
progress=progress,
desc=f"Sweep ({solver_fn})",
)
# ------------------------------------------------------------------
# Typed batched-IR surface
# ------------------------------------------------------------------
[docs]
def prepare_hamiltonian(
self,
description: Any,
tlist: Any | None = None,
) -> PreparedHamiltonian:
r"""Convert a :class:`EngineResult` into a ``Qobj`` or ``QobjEvo``.
Each dynamic coefficient is band-normalized. Every carrier stays
analytic, and only its slow, carrier-free envelope is sampled on
*tlist*, locally densified around each window edge. This avoids the
cubic-spline error of a pre-sampled full ``envelope·carrier`` product,
even for resonant carriers in the lab frame.
Parameters
----------
description : EngineResult
Captured engine Hamiltonian and channels in solver units.
tlist : array_like
Requested save times in ns, used by native time-dependent lowering.
Returns
-------
PreparedHamiltonian
Native right-hand side and numerical integration hints.
See Also
--------
quchip.backend.protocol.Backend.prepare_hamiltonian
"""
# The envelope sampling and edge densification are implemented in
# `_assemble_qobjevo` / `_augmented_sample_grid`.
static_rhs = self._sum_terms(description.static_terms, self._canonical_to_qobj)
metadata = dict(description.metadata)
if not description.dynamic_terms:
if static_rhs is None:
raise ValueError("EngineResult must contain at least one static or dynamic term.")
return PreparedHamiltonian(rhs=static_rhs, metadata=metadata)
sample_tlist = self._resolve_envelope_sample_tlist(tlist)
op_signal_pairs = (
(self._canonical_to_qobj(operator), signal)
for operator, signal in self._scalar_dynamic_terms(description)
)
rhs = _assemble_qobjevo(static_rhs, op_signal_pairs, sample_tlist)
return PreparedHamiltonian(rhs=rhs, metadata=metadata)
[docs]
def prepare_batch(self, batch: Any) -> DeferredBatch:
r"""Build a deferred-construction batch whose workers build each element's ``QobjEvo``.
Only the slow, carrier-free envelope is sampled on the user grid,
locally densified around each window edge, and the carriers stay
analytic.
Parameters
----------
batch : SolveBatch
Batch of captured problems with compatible save grids.
Returns
-------
PreparedBatch
Eager, native-vectorized, or deferred representation selected by this backend.
See Also
--------
quchip.backend.protocol.Backend.prepare_batch
"""
# Each unique `CanonicalOperator` is converted once and shared across
# elements. The envelope sampling is implemented in `_assemble_qobjevo`.
# The final `QobjEvo` assembly occurs in `solve_batch`, so loky workers
# can build and solve each point of larger concrete sweeps.
cached_qobj = self._make_op_cache()
engine_results = tuple(problem.engine_result for problem in batch.problems)
static_cache: dict[tuple[int, ...], Qobj | None] = {}
static_rhs: list[Qobj | None] = []
for result in engine_results:
key = result._static_term_ids
if key not in static_cache:
static_cache[key] = self._sum_terms(result.static_terms, cached_qobj)
static_rhs.append(static_cache[key])
dynamic_qobjs = tuple(
tuple(cached_qobj(term.operator) for term in result.dynamic_terms)
for result in engine_results
)
sample_tlist: Any = None
if any(dynamic_qobjs):
sample_tlist = self._resolve_envelope_sample_tlist(batch.tlist)
shared = _QuTiPBatchShared(
static_rhs=tuple(static_rhs),
dynamic_qobjs=dynamic_qobjs,
sample_tlist=sample_tlist,
dynamic_signals=tuple(
tuple(term.time_dependence for term in result.dynamic_terms)
for result in engine_results
),
)
return DeferredBatch(
shared=shared,
batch_size=batch.batch_size,
metadata=dict(engine_results[0].metadata),
tlist=batch.tlist,
)
[docs]
def solve_batch(
self, batch: Any, *, progress: bool = True, max_workers: int | None = None,
) -> list[SolverResult]:
r"""Solve a :class:`SolveBatch`, with the ``QobjEvo`` for each element built in loky workers.
Parameters
----------
batch : SolveBatch
Captured problems and batch parameter values.
progress : bool, default True
Request a batch progress display.
max_workers : int or None, default None
Worker processes. ``None`` takes the CPU count that loky reports,
which honors ``LOKY_MAX_CPU_COUNT``, or fewer when the available
memory cannot hold them. ``1`` solves in this process.
Returns
-------
list of SolverResult
Results in batch order.
See Also
--------
quchip.backend.protocol.Backend.solve_batch
"""
if any(problem.stochastic for problem in batch.problems):
return self._parallel_map(task=self.solve_problem, items=list(batch.problems),
max_workers=1, progress=progress, desc="quchip trajectories")
if batch.batch_size == 0:
return []
prepared = self.prepare_batch(batch)
tlist_arr = self.array_module.asarray(batch.tlist, dtype=float)
shared = prepared.shared
if not isinstance(shared, _QuTiPBatchShared):
raise RuntimeError(
"QuTiPBackend.solve_batch requires DeferredBatch.shared to be a "
f"_QuTiPBatchShared instance, got {type(shared).__name__}."
)
sample_tlist = shared.sample_tlist
tasks: list[_QuTiPBatchTask] = []
for index, problem in enumerate(batch.problems):
engine_result = problem.engine_result
c_ops = self._collapse_operators(engine_result)
solver_name = problem.solver_name(self)
opts = self._resolve_problem_options(
problem,
metadata=engine_result.metadata,
tlist=tlist_arr,
solver_name=solver_name,
)
tasks.append(
_QuTiPBatchTask(
static_rhs=shared.static_rhs[index],
dynamic_qobjs=shared.dynamic_qobjs[index],
dynamic_signals=shared.dynamic_signals[index],
initial_state=self.coerce_state(
problem.initial_state,
dims=problem.engine_result.dims,
),
c_ops=tuple(c_ops),
solver_name=solver_name,
options=dict(opts),
e_ops=(tuple(problem.e_ops) if isinstance(problem.e_ops, list) else None),
)
)
def build_and_solve(task: _QuTiPBatchTask) -> SolverResult:
op_signal_pairs = (
(operator, signal.signal)
for operator, signal in zip(task.dynamic_qobjs, task.dynamic_signals)
)
rhs = _assemble_qobjevo(task.static_rhs, op_signal_pairs, sample_tlist)
kwargs = self._element_solver_kwargs(
task.solver_name,
rhs,
task.initial_state,
tlist_arr,
e_ops=list(task.e_ops) if task.e_ops is not None else None,
c_ops=list(task.c_ops),
options=task.options,
)
return getattr(self, task.solver_name)(**kwargs)
reference = tasks[0]
operators = ([op for op in (reference.static_rhs, *reference.dynamic_qobjs) if op is not None],
reference.c_ops)
return self._parallel_map(
task=build_and_solve,
items=tasks,
max_workers=max_workers,
point_bytes=self._point_bytes(batch.problems[0], operators),
progress=progress,
desc=f"Sweep ({reference.solver_name})",
)
# ------------------------------------------------------------------
# Internal: per-element RHS construction
# ------------------------------------------------------------------
@staticmethod
def _resolve_envelope_sample_tlist(tlist: Any) -> Any:
"""Return the base sample grid for interpolating carrier-free slow envelopes; ``None`` otherwise.
Carriers are kept analytic (see :func:`_assemble_qobjevo`), so no
dense per-carrier oversampling is needed — only the slow envelope
is interpolated. This grid's *density* governs fidelity for a
**non-windowed** envelope only: a too-coarse output tlist is
replaced by a uniform grid of ``_MIN_ENVELOPE_SAMPLES`` points
over the same span, and a user grid at least that dense is used
as-is. For a **windowed** envelope this method's output feeds only
:func:`_augmented_sample_grid`'s canonical skeleton, which reads
just this grid's *endpoints* — window fidelity there comes
entirely from the window's own local subgrid, independent of
whatever density this method happened to return, so the output
tlist never determines a windowed pulse's interpolation fidelity.
Returns ``None`` (callable coefficients) when the grid is too
short, degenerate, or its endpoints are JAX tracers, mirroring the
dynamiqs path.
"""
if tlist is None or len(tlist) < 2:
return None
t0 = maybe_concrete_scalar(tlist[0])
t1 = maybe_concrete_scalar(tlist[-1])
if t0 is None or t1 is None or t1 <= t0:
return None
if len(tlist) >= _MIN_ENVELOPE_SAMPLES:
return tlist
return np.linspace(float(t0), float(t1), _MIN_ENVELOPE_SAMPLES)
# ------------------------------------------------------------------
# Internal: loky parallel-map helper
# ------------------------------------------------------------------
[docs]
def warmup(self, n_jobs: int = -1) -> None:
"""Start the reusable solver pool.
Parameters
----------
n_jobs : int, default -1
Worker count. ``-1`` takes the CPU count that loky reports.
"""
try:
workers = self._worker_count(None if n_jobs in (-1, None) else n_jobs, 0)
executor = self._get_executor(workers)
except (ModuleNotFoundError, ImportError) as exc:
warnings.warn(
f"QuTiP sweep warmup unavailable ({exc!r}); the loky pool will "
"spin up lazily on the first sweep instead.",
stacklevel=2,
)
return
list(executor.map(_warmup_noop, range(workers)))
def _point_bytes(self, problem: Any, operators: tuple[Any, Sequence[Any]] | None = None) -> int:
"""Estimate the memory that one batch point needs in a worker, besides the worker's imports.
The estimate counts the saved states and, for a master equation whose
native Hamiltonian parts and collapse operators are given, the peak of
its Liouvillian assembly.
"""
dimension = math.prod(problem.engine_result.dims)
solver = problem.solver_name(self)
state = dense_bytes(dimension) if solver == "sesolve" else dense_bytes(dimension, dimension)
saved = {"final": 1, "none": 0}.get(problem.states, len(problem.tlist))
if solver == "mesolve" and operators is not None:
return saved * state + _superoperator_peak_bytes(*operators)
return saved * state
@staticmethod
def _worker_count(max_workers: int | None, point_bytes: int) -> int:
"""Return the pool size for a batch whose points each need ``point_bytes`` in a worker.
An explicit count that cannot fit in the available memory raises
:class:`MemoryError`. The default, the CPU count that loky reports,
drops with a warning to the largest count that fits.
"""
from loky import cpu_count
def required(count: int) -> int:
# Workers of the running pool have their imports already.
started = max(0, count - QuTiPBackend._pool_workers)
return count * point_bytes + started * _WORKER_BYTES
if max_workers is not None:
if max_workers > 1:
require_memory(required(max_workers), task=f"A batch on {max_workers} worker processes",
remedy="Pass a smaller max_workers.")
return max_workers
workers = cpu_count()
if fits_memory(required(workers)):
return workers
fitting = next((count for count in range(workers - 1, 0, -1) if fits_memory(required(count))), 1)
warnings.warn(
f"The batch runs on {fitting} of {workers} worker processes, because each needs about "
f"{(point_bytes + _WORKER_BYTES) / 1e9:.2f} GB and the available memory holds no more. "
"Pass max_workers to choose the count.",
stacklevel=2,
)
return fitting
@staticmethod
def _get_executor(workers: int) -> Any:
"""Return the process-wide reusable loky executor with ``workers`` processes.
``reuse=True`` hands back the same live pool across sweeps, resized when
the count changes; the long idle timeout avoids loky's ~10 s-default
respawn, which otherwise stalls the following sweep by ~3 s.
"""
from loky import get_reusable_executor
executor = get_reusable_executor(max_workers=workers, reuse=True, timeout=_POOL_IDLE_TIMEOUT_S)
QuTiPBackend._pool_workers = workers
return executor
@staticmethod
def _shutdown_executor() -> None:
"""Shut down the pool (best-effort) so a poisoned reused pool self-heals next sweep."""
QuTiPBackend._pool_workers = 0
try:
from loky import get_reusable_executor
get_reusable_executor(reuse=True).shutdown(wait=False)
except Exception:
pass
def _parallel_map(
self,
*,
task: Callable[[Any], SolverResult],
items: list[Any],
max_workers: int | None,
point_bytes: int = 0,
progress: bool,
desc: str,
) -> list[SolverResult]:
"""Run *task* over *items*, picking the cheapest dispatch path.
Small batches (``< _PARALLEL_MIN_BATCH``) and ``max_workers=1`` run
sequentially in-process — the loky pool's fork/import/IPC overhead
dominates a handful of fast QuTiP solves. Larger batches dispatch
through the process-wide reusable loky executor, sized by
:meth:`_worker_count`. Worker-pool failures may use sequential
execution; failures raised by an individual solve retain their point
index and propagate without retrying the numerical work.
"""
from tqdm import tqdm
def indexed_task(entry: tuple[int, Any]) -> SolverResult:
index, item = entry
try:
return task(item)
except Exception as exc:
raise BatchSolveError(index, f"{type(exc).__name__}: {exc}") from exc
def sequential() -> list[SolverResult]:
iterator = tqdm(enumerate(items), total=len(items), desc=desc) if progress else enumerate(items)
return [indexed_task(entry) for entry in iterator]
if max_workers == 1 or len(items) < self._PARALLEL_MIN_BATCH:
return sequential()
try:
workers = self._worker_count(max_workers, point_bytes)
if workers == 1:
return sequential()
executor = self._get_executor(workers)
except (ModuleNotFoundError, ImportError) as exc:
warnings.warn(
f"QuTiP batched solve parallelism unavailable ({exc!r}); "
"falling back to sequential execution.",
stacklevel=2,
)
return sequential()
try:
mapped = executor.map(indexed_task, enumerate(items))
if progress:
return list(tqdm(mapped, total=len(items), desc=desc))
return list(mapped)
except BatchSolveError:
raise
except Exception as exc:
self._shutdown_executor()
warnings.warn(
f"QuTiP batched solve parallelism unavailable ({exc!r}); "
"falling back to sequential execution.",
stacklevel=2,
)
return sequential()
# ------------------------------------------------------------------
# Internal: Qobj <-> CanonicalOperator conversion
# ------------------------------------------------------------------
@staticmethod
def _coerce_solver_rhs(H: Any) -> Any:
"""Ensure *H* is a ``Qobj`` or ``QobjEvo`` accepted by QuTiP solvers."""
if isinstance(H, (Qobj, qutip.QobjEvo)):
return H
return qutip.QobjEvo(H)
@staticmethod
def _canonical_to_qobj(canonical: Any) -> Qobj:
"""Reconstruct a ``Qobj`` from a ``CanonicalOperator`` (dense or sparse).
A dense payload that is mostly exact zeros, such as a band of a captured
reduced-model matrix, is stored as CSR so the superoperators QuTiP builds
from it stay sparse.
"""
dims = [list(canonical.dims), list(canonical.dims)]
if canonical.layout == "dense":
values = np.asarray(canonical.values, dtype=complex)
if np.count_nonzero(values) > _CSR_MAX_FILL * values.size:
return Qobj(values, dims=dims, dtype="Dense")
return Qobj(sparse.csr_matrix(values), dims=dims, dtype="CSR")
return Qobj(QuTiPBackend._canonical_to_csr_matrix(canonical), dims=dims, dtype="CSR")
@staticmethod
def _canonical_to_csr_matrix(canonical: Any) -> sparse.csr_matrix:
"""Convert any canonical layout (``csr``/``dia``/dense fallback) to SciPy CSR."""
if canonical.layout == "csr":
return sparse.csr_matrix(
(
np.asarray(canonical.values, dtype=complex),
np.asarray(canonical.indices, dtype=int),
np.asarray(canonical.indptr, dtype=int),
),
shape=canonical.shape,
)
if canonical.layout == "dia":
dia = sparse.dia_matrix(
(
np.asarray(canonical.values, dtype=complex),
np.asarray(canonical.offsets, dtype=int),
),
shape=canonical.shape,
)
return dia.tocsr()
return sparse.csr_matrix(np.asarray(canonical.values, dtype=complex))
# ------------------------------------------------------------------
# Internal: SolverResult packaging
# ------------------------------------------------------------------
@staticmethod
def _wrap_result(
qutip_result: Any,
solver: str,
*,
extra_stats: dict[str, Any] | None = None,
) -> SolverResult:
"""Convert a ``qutip.Result`` to the backend-agnostic :class:`SolverResult`."""
states = list(qutip_result.states) if qutip_result.states else None
expect = [list(e) for e in qutip_result.expect] if qutip_result.expect else None
stats = dict(qutip_result.stats) if getattr(qutip_result, "stats", None) else {}
if extra_stats:
stats.update({key: value for key, value in extra_stats.items() if value is not None})
final_state = states[-1] if states else getattr(qutip_result, "final_state", None)
return SolverResult(
times=qutip_result.times,
states=states,
expect=expect,
final_state=final_state,
stats=stats,
solver=solver,
)
@staticmethod
def _solver_stats(solver_runner: Any) -> dict[str, Any]:
"""Capture effective solver options and the step count when exposed."""
stats: dict[str, Any] = {"options": dict(solver_runner.options)}
nsteps = QuTiPBackend._extract_nsteps(solver_runner)
if nsteps is not None:
stats["nsteps"] = nsteps
return stats
@staticmethod
def _extract_nsteps(solver_runner: Any) -> int | None:
"""Return the ODEPACK/LSODA step count via QuTiP/scipy internals; ``None`` if unavailable.
Reaches into undocumented QuTiP/scipy plumbing — may break on
upgrades. Purely diagnostic; no physics depends on it.
"""
integrator = getattr(solver_runner, "_integrator", None)
ode_solver = getattr(integrator, "_ode_solver", None)
inner = getattr(ode_solver, "_integrator", None)
if inner is None:
return None
iwork = getattr(inner, "iwork", None)
if iwork is not None and len(iwork) > 10:
# ODEPACK stores NST (successful internal steps) in IWORK(11).
nsteps = int(iwork[10])
if nsteps > 0:
return nsteps
nst = getattr(inner, "nst", None)
if nst is not None:
nsteps = int(nst)
if nsteps > 0:
return nsteps
return None