"""Backend-agnostic wrapper around solver output.
The engine wraps each backend's solver output here, so callers use the same
state, expectation, partial-trace, population, and batch interfaces.
Numerical population and overlap accessors return NumPy arrays for QuTiP and
JAX arrays for Dynamiqs, even for eager calls. Host conversion is explicit.
"""
from __future__ import annotations
import itertools
from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
from quchip.backend import Backend, SolverResult
from quchip.devices.base import BaseDevice
from quchip.results._batch import BatchResult
from quchip.utils.constants import TWO_PI
from quchip.utils.labeling import resolve_label
# Mirrors quchip.viz.results.StateMode so the plot_state shim can re-expose the
# same literal without a hard import of the lazily loaded viz module.
StateMode = Literal["population", "dm"]
Frame = Literal["integration", "lab"]
if TYPE_CHECKING:
from quchip.engine.ir import SLHChannel, SolveBatch, SolveProblem
[docs]
@dataclass(frozen=True)
class ObservableTrace:
"""One named expectation-value trace, with pre- and post-processing values.
Attributes
----------
values
Post-processed expectation values over time (e.g. demodulated,
phase-corrected, band-summed). User code usually reads these.
raw
The same quantity before post-processing. Use it to debug frame
conventions, band decomposition, and demodulation.
"""
values: Any
raw: Any
[docs]
@dataclass(frozen=True)
class OutputFieldTrace:
"""Complete transient field reported at one external reference plane.
``amplitude`` is the complex mean field ``<b_out>``. ``photon_flux`` is the
normally ordered ``<b_out dagger b_out>`` in photons/ns. ``raw_*`` keep the
same moments at the Markov boundary, before propagation through the outbound
reference run. Complex amplitudes use the physics ``e^{-iωt}`` convention.
Conjugate ``raw_amplitude`` to give a boundary template to ``iq_readout()``.
Attributes
----------
exposure : str
External network-port label.
times : array_like
Saved times in ns.
amplitude, raw_amplitude : array_like
Propagated and Markov-boundary complex means in ``1/sqrt(ns)``.
photon_flux, raw_photon_flux : array_like
Propagated and Markov-boundary normally ordered fluxes in photons/ns.
"""
exposure: str
times: Any
amplitude: Any
photon_flux: Any
raw_amplitude: Any
raw_photon_flux: Any
[docs]
def quadrature(self, phase: Any = 0.0) -> Any:
r"""Return ``Re[exp(-i phase) <b_out>]`` without another solve.
Parameters
----------
phase : scalar, default=0.0
Quadrature angle in radians.
"""
from quchip.utils.jax_utils import array_namespace
xp = array_namespace(self.amplitude)
return xp.real(xp.exp(-1j * xp.asarray(phase)) * self.amplitude)
@property
def final_amplitude(self) -> Any:
"""Return the final complex output amplitude."""
return self.amplitude[..., -1]
@property
def final_photon_flux(self) -> Any:
"""Return the final normally ordered photon flux."""
return self.photon_flux[..., -1]
_NO_STATES_MSG = 'Full state history is unavailable; run with states="all" to retain it.'
[docs]
class SimulationResult:
"""Backend-agnostic container for the output of one solve.
Stored states follow the requested storage policy. ``times`` uses ns, and
``dims`` follows chip order. Dict-form ``e_ops`` become
:class:`ObservableTrace` entries in ``observable_traces``.
Parameters
----------
solver_result : SolverResult
Backend output to wrap.
backend : Backend
Backend that owns the native states and arrays.
dims : sequence of int
Hilbert dimensions in chip order.
device_info, observable_traces, output_traces, channels, bases : optional
Captured device, observable, output, channel, and basis metadata.
dissipation : bool, default=True
Whether collapse channels were included.
excitation_space : optional
Captured exact excitation coordinate map, or None for product coordinates.
readout_wiring : optional
Captured downstream readout wiring.
frame : ResolvedFrame or None, optional
Integration frame of the saved states.
label_targets : LabelTargets or None, optional
Snapshot of the solve's chip that prepares label targets.
Attributes
----------
frame : ResolvedFrame or None
Integration frame of the saved states: the frame frequency of each device and the frame mode.
"""
def __init__(
self,
solver_result: SolverResult,
backend: Backend,
dims: list[int] | tuple[int, ...],
*,
device_info: list[tuple[str, bool]] | tuple[tuple[str, bool], ...] | None = None,
observable_traces: dict[Any, ObservableTrace | list[ObservableTrace]] | None = None,
output_traces: dict[Any, OutputFieldTrace] | None = None,
channels: tuple[SLHChannel, ...] = (),
bases: dict[str, Any] | None = None,
dissipation: bool = True,
readout_wiring: Any = None,
excitation_space: Any = None,
frame: Any = None,
label_targets: Any = None,
) -> None:
self.frame = frame
self._label_targets = label_targets
self._frame_map_cache: Any = None
self._expanded_states: dict[int, Any] = {}
self._excitation_space = excitation_space
self._readout_wiring = readout_wiring
self._backend = backend
self._truncation: Any = None
self._boundary_traces: tuple[Any, ...] | None = None
self.dissipation = dissipation
self._bases = {} if bases is None else dict(bases)
self._channels: dict[str, SLHChannel] = {channel.key: channel for channel in channels}
self._jump_rate_cache: dict[str, Any] = {}
self.times = backend.array_module.asarray(solver_result.times, dtype=float)
self._states = solver_result.states
self._expect_data: dict[Any, ObservableTrace | list[ObservableTrace]] | None = observable_traces
self._output_data = {} if output_traces is None else dict(output_traces)
self.solver = solver_result.solver
self.stats = dict(solver_result.stats) if solver_result.stats else {}
self.dims = list(dims)
self.device_info = device_info
self._final_state = solver_result.final_state
# Cache native trajectories and the labels used by the full population dictionary.
self._stacked_cache: Any = None
self._basis_labels_cache: list[tuple[int, ...]] | None = None
# ------------------------------------------------------------------
# Observables — dict-form expectation traces
# ------------------------------------------------------------------
@property
def observable_traces(self) -> dict[Any, ObservableTrace | list[ObservableTrace]] | None:
"""Return the dict of named :class:`ObservableTrace` entries, or ``None``.
The value is ``None`` when ``e_ops`` was not a dict.
"""
# Visualization and analysis code that needs the full dict should read
# this property, not the private `_expect_data` attribute.
return self._expect_data
def _resolve_trace(self, key: Any, index: int | None = None) -> ObservableTrace:
if not isinstance(self._expect_data, dict):
raise TypeError("Expectation-value access requires dict-form e_ops (pass e_ops as a dict)")
resolved_key: Any = (
tuple(resolve_label(k) for k in key) if isinstance(key, tuple) else resolve_label(key)
)
trace = self._expect_data[resolved_key]
if isinstance(trace, list):
if index is None:
raise ValueError(
f"expect[{key!r}] contains {len(trace)} traces; specify index=0..{len(trace) - 1}"
)
return trace[index]
if index is not None:
raise ValueError(f"expect[{key!r}] is a single trace, not a list; drop the index argument")
return trace
[docs]
def expect(self, key: Any, index: int | None = None) -> Any:
"""Return an expectation trace over ``self.times``.
Parameters
----------
key : object or str
Captured observable key.
index : int or None, optional
Entry of a list-valued observable.
"""
return self._resolve_trace(key, index).values
[docs]
def observable_at(self, t: Any, values: Any, *, method: str = "exact") -> Any:
"""Select saved observable values at scalar or array times.
Values have time on their last axis, and the result has their leading
axes followed by the query shape. ``nearest`` selects the earlier
sample on a tie. ``interpolate`` is linear, including for complex
values. Out-of-interval queries raise an error. No solve or state
interpolation occurs.
Parameters
----------
t : scalar or array_like
Query times in ns.
values : array_like
Saved values with time on the final axis.
method : {"exact", "nearest", "interpolate"}, default="exact"
Time-selection rule.
"""
from quchip.results._time import observable_at
return observable_at(self.times, t, values, method, self._backend.array_module)
[docs]
def expect_final(self, key: Any, index: int | None = None) -> Any:
"""Return the final expectation value for an observable.
Parameters
----------
key : object or str
Captured observable key.
index : int or None, optional
Entry of a list-valued observable.
"""
return self._resolve_trace(key, index).values[-1]
# Alias: reads nicely at call sites that say "give me the values array".
expect_values = expect
# ------------------------------------------------------------------
# External fields
# ------------------------------------------------------------------
@property
def outputs(self) -> dict[Any, OutputFieldTrace]:
"""Return complete output-field traces keyed by network port label."""
return dict(self._output_data)
[docs]
def output(self, exposure: Any) -> OutputFieldTrace:
"""Return one field trace by network port or label.
Parameters
----------
exposure : port object or str
External output reference plane.
"""
label = resolve_label(exposure)
try:
return self._output_data[label]
except KeyError as exc:
raise KeyError(
f"No output for {label!r}. Available network ports: {sorted(self._output_data)}"
) from exc
# ------------------------------------------------------------------
# Collapse channels
# ------------------------------------------------------------------
@property
def collapse_channels(self) -> tuple[str, ...]:
"""Return the resolved SLH channel keys accepted by :meth:`jump_rate`.
Hidden channels use keys such as ``"hidden.q.thermal_emission"`` and
``"hidden.r.internal_photon_loss"``. Channels exposed at external
ports use their network labels.
"""
return tuple(self._channels)
def _channel_key(self, key: Any) -> str:
label = resolve_label(key)
if label in self._channels:
return label
matches = [name for name in self._channels if name.split("#")[0].partition(".")[2] == label]
if len(matches) == 1:
return matches[0]
detail = "matches several channels" if matches else "matches no channel"
raise KeyError(f"{label!r} {detail}. Available channels: {list(self._channels)}")
[docs]
def jump_rate(self, key: Any) -> Any:
"""Return the cached jump rate ``<L†L>(t)`` for one resolved channel.
The rate, in ``1/ns``, is evaluated after the solve from stored states.
``key`` can be a resolved key from :attr:`collapse_channels`, a network
port, or a ``"<device>.<channel>"`` label that identifies one channel.
Address a port composed into a network through its external network
port.
At an external network port, this rate equals ``raw_photon_flux`` only with
vacuum input. Dephasing and thermal-absorption channels can also have
nonzero jump rates, so this quantity alone is not an excitation-loss rate.
Parameters
----------
key : channel object or str
Resolved channel key, external port, or unambiguous local label.
"""
name = self._channel_key(key)
if name not in self._jump_rate_cache:
from quchip.engine.observables import collapse_number_operator
operator = collapse_number_operator(self._channels[name].coupling, self._backend, tag=f"flux:{name}")
flux = self._backend.array_module.real(self._backend.expect_over_time(operator, self._stacked_states()))
from quchip.utils.jax_utils import contains_tracer
if not contains_tracer(flux):
self._jump_rate_cache[name] = flux
return flux
return self._jump_rate_cache[name]
[docs]
def collapse_flux(self, key: Any) -> Any:
"""Deprecated alias for :meth:`jump_rate`.
Parameters
----------
key : channel object or str
Resolved channel key.
"""
from quchip.utils.deprecation import warn_renamed
warn_renamed("collapse_flux()", "jump_rate()")
return self.jump_rate(key)
[docs]
def collapse_integral(self, key: Any) -> Any:
"""Return the cumulative expected jump count for one channel.
The cumulative trapezoid integral of :meth:`jump_rate` on the result
grid. Its last entry is the expected number of jumps over the whole
solve.
Parameters
----------
key : channel object or str
Resolved channel key.
"""
xp = self._backend.array_module
flux = self.jump_rate(key)
increments = 0.5 * (flux[1:] + flux[:-1]) * (self.times[1:] - self.times[:-1])
return xp.concatenate([xp.zeros((1,), dtype=increments.dtype), xp.cumsum(increments)])
# ------------------------------------------------------------------
# States, partial traces, overlaps
# ------------------------------------------------------------------
@property
def states(self) -> Any:
"""Return the kept native state history, or tell how to save it."""
if self._states is None or len(self._states) == 0:
raise RuntimeError(_NO_STATES_MSG)
if self._excitation_space is not None:
from math import prod
from quchip.utils.memory import dense_bytes, require_memory
ket = self._backend.is_ket(self._states[0])
# A stacked history keeps the cached states and a new stack.
copies = 1 if isinstance(self._states, list) else 2
dimension = prod(self.dims)
require_memory(dense_bytes(len(self._states), *((dimension,) if ket else (dimension, dimension)),
copies=copies),
task="Full pulse history export", remedy="Use compact_state() or local observables.")
lifted = [self._export_state(state, index) for index, state in enumerate(self._states)]
return lifted if isinstance(self._states, list) else self._backend.stack_states(lifted)
return self._states
def _stacked_states(self) -> Any:
"""Return the trajectory as one ``(T, …)`` native stacked array (cached).
This is the batched form the over-time extractors consume — built
once per result. On dynamiqs it is the solver's own stacked
``QArray`` (no copy); on QuTiP it is a single ``np.stack`` of the
stored ``Qobj``s. Keeping it as one array (rather than ``T`` separate
objects) is what collapses the per-point extractor loop.
"""
cached = getattr(self, "_stacked_cache", None)
if cached is None:
if self._states is None or len(self._states) == 0:
raise RuntimeError(_NO_STATES_MSG)
cached = self._backend.stack_states(self._states)
from quchip.utils.jax_utils import contains_tracer
if not contains_tracer(cached):
self._stacked_cache = cached
return cached
def _is_ket_trajectory(self) -> bool:
"""Return whether the stored trajectory is kets (vs density matrices)."""
return self._backend.is_ket(self._stacked_states()[0])
@property
def _basis_labels(self) -> list[tuple[int, ...]]:
"""Cache solver-product labels for the full population dictionary."""
cached = self._basis_labels_cache
if cached is None:
cached = list(itertools.product(*[range(d) for d in self.dims]))
self._basis_labels_cache = cached
return cached
def _export_state(self, state, index):
"""Lift one saved compact state, caching it by its time index."""
if self._excitation_space is None:
return state
from quchip.utils.jax_utils import contains_tracer
key = None if contains_tracer(index) else int(index)
if key in self._expanded_states:
return self._expanded_states[key]
lifted = self._excitation_space.lift(state, self._backend)
if key is not None and not contains_tracer(lifted):
self._expanded_states[key] = lifted
return lifted
def _target_state(self, target):
from quchip.engine.states import CompactState, compatible_values
backend = self._backend
space = self._excitation_space
if isinstance(target, CompactState):
if target.values.shape[-1] != 1:
raise TypeError("Overlap targets must be kets.")
values = compatible_values(
target, tuple(label for label, _ in self.device_info), self.dims, self._bases, backend,
)
if space is not None:
xp = backend.array_module
# The saved states have no amplitude outside this space.
columns = np.asarray([target.space.rows.get(tuple(level), target.space.size) for level in space.levels])
padded = xp.concatenate([values, xp.zeros((1, 1), dtype=values.dtype)])
return backend.from_array(padded[columns], dims=[[space.size], [1]])
native = backend.from_array(values, dims=[[target.space.size], [1]])
return target.space.lift(native, backend)
native = backend.coerce_state(target, dims=tuple(self.dims))
return native if space is None else space.project(native, backend)
def _frame_map(self):
"""Return the map between the lab frame and the frame of the saved states."""
from quchip.engine.frames import FrameMap
from quchip.utils.jax_utils import contains_tracer
if self._frame_map_cache is not None:
return self._frame_map_cache
if self.frame is None or self.device_info is None:
raise RuntimeError("This result has no captured frame; it was not created by a solve.")
frame_map = FrameMap.create(self.frame, tuple(label for label, _ in self.device_info), self._bases,
self._backend.array_module, space=self._excitation_space)
if not contains_tracer(frame_map.energies):
self._frame_map_cache = frame_map
return frame_map
def _saved_dims(self) -> list[int]:
return list(self.dims if self._excitation_space is None else self._excitation_space.solver_dims)
def _in_frame(self, state, index, frame):
"""Return a saved native state in the requested frame, in saved coordinates."""
if _frame_choice(frame) == "integration":
return state
backend = self._backend
values = backend.to_array(state)
time = self.times[index]
frame_map = self._frame_map()
ket = backend.is_ket(state)
mapped = (frame_map.kets(values.reshape(-1), time, to_lab=True).reshape(values.shape) if ket
else frame_map.operators(values, time, to_lab=True))
dims = self._saved_dims()
return backend.from_array(mapped, dims=[dims, [1] * len(dims) if ket else dims])
def _exported(self, state, index, frame):
"""Return a saved state in captured product coordinates and the requested frame."""
if _frame_choice(frame) == "integration":
return self._export_state(state, index)
state = self._in_frame(state, index, frame)
return state if self._excitation_space is None else self._excitation_space.lift(state, self._backend)
def _resolve_target(self, target, frame, times):
"""Return a fixed native target, or an array with one target ket per time.
A ket keeps its integration-frame meaning unless ``frame`` is ``"lab"``.
A label k is its dressed state, which turns with its label:
``U(t)†|d_k⟩ exp(−i 2π t Σᵢ fᵢ kᵢ)``. The all-ground label of a
conserving model is the fixed bare vacuum.
"""
frame = _frame_choice(frame)
backend = self._backend
if isinstance(target, (Mapping, str)):
if frame == "lab":
raise ValueError("A label target turns with its label in every frame; frame= applies to ket targets.")
if self._label_targets is None:
raise RuntimeError("This result has no captured chip for label targets; it was not created by a solve.")
levels = self._label_targets.levels(target, self.dims)
space = self._excitation_space
vector = self._label_targets.dressed(levels, self._bases, space)
if vector is None:
vector = self._label_targets.bare(levels, self._bases, space)
dims = self._saved_dims()
return backend.from_array(vector.reshape(-1, 1), dims=[dims, [1] * len(dims)]), None
frame_map = self._frame_map()
xp = backend.array_module
phase = xp.exp(-1j * TWO_PI * xp.asarray(times) * frame_map.label_energy(levels))
return None, phase[:, None] * frame_map.kets(vector, times)
fixed = self._target_state(target)
if frame == "integration":
return fixed, None
return None, self._frame_map().kets(backend.to_array(fixed).reshape(-1), times)
[docs]
def compact_state(self, t: Any = None) -> Any:
"""Return a reusable saved state without product-space expansion.
Parameters
----------
t : scalar or None, optional
Saved time in ns. None selects the final state.
Returns
-------
CompactState
Captured excitation state accepted as initial_state by a later solve.
"""
from quchip.engine.states import CompactState
if self._excitation_space is None:
raise ValueError("This result stores full product states. Use state() to reuse them.")
state = self._raw_final_state() if t is None else self._raw_state_at(t)
return CompactState(self._backend.to_array(state), self._excitation_space)
[docs]
def overlap(self, target: Any, *, frame: Frame = "integration") -> Any:
"""Return the overlap with *target* at every stored time.
For ket trajectories, the method returns ``|<target|psi(t)>|**2``. For
density matrices, it returns ``<target|rho(t)|target>``. The result
stays in the backend's array module, so it is differentiable. One
batched op covers the leading time axis, with no per-point loop.
A ket target is a fixed vector of the integration frame, so a target
such as |+⟩ is written in that frame, and ``chip.bare_state(...)``
gives a bare product. With ``frame="lab"``, the target is a fixed
lab-frame vector, such as a dressed state from ``chip.state()``. A
label target, such as ``{"q0": 0, "q1": 1}``, is the dressed
eigenstate ``|d_k⟩`` of label k, which turns with its label:
``U(t)†|d_k⟩ exp(−i 2π t Σᵢ fᵢ kᵢ)``, where ``U(t)†`` maps the lab
frame into the integration frame and ``fᵢ`` are the frame
frequencies. An idle dressed state then reads 1 in every frame. A
snapshot of the solve's chip supplies ``|d_k⟩``, with the solve's
approximation and excitation cutoff.
Parameters
----------
target : state_like, mapping, or str
Target ket in the captured full Hilbert space, a compatible ``CompactState``,
or a label of local energy levels, which names its dressed state.
frame : {"integration", "lab"}, default="integration"
Frame in which a ket target is fixed.
"""
backend = self._backend
target, moving = self._resolve_target(target, frame, self.times)
if moving is not None:
return _moving_projection(backend, moving, self._stacked_states(), amplitude=False)
vector = backend.to_array(target)
dims = self.dims if self._excitation_space is None else self._excitation_space.solver_dims
projector = backend.from_array(vector @ backend.array_module.conj(vector.T),
dims=[list(dims), list(dims)])
series = backend.expect_over_time(projector, self._stacked_states())
return backend.array_module.abs(series)
[docs]
def amplitude_array(self, target: Any, *, frame: Frame = "integration") -> Any:
"""Return the phase-sensitive complex projection ``<target|psi(t)>`` for kets.
Density-matrix trajectories raise :class:`TypeError`, because a mixed
state has no single phase-sensitive amplitude. For a mixed state, use
:meth:`overlap`. The method uses one batched op, with no per-point
loop. Targets and ``frame`` are as in :meth:`overlap`. With
``frame="lab"``, the phase is the lab-frame phase. A label target gives
a phase that turns at ``Σᵢ fᵢ kᵢ − E_k``, the detuning of the label's
frame energy from its dressed energy ``E_k``.
Parameters
----------
target : state_like, mapping, or str
Target ket in the captured full Hilbert space, a compatible ``CompactState``,
or a label of local energy levels, which names its dressed state.
frame : {"integration", "lab"}, default="integration"
Frame in which a ket target is fixed.
"""
backend = self._backend
if not self._is_ket_trajectory():
raise TypeError(
"amplitude_array() requires ket trajectories; use overlap() for density matrices."
)
target, moving = self._resolve_target(target, frame, self.times)
if moving is not None:
return _moving_projection(backend, moving, self._stacked_states(), amplitude=True)
return backend.array_module.asarray(
backend.overlap_over_time(target, self._stacked_states())
)
def _resolve_device_idx(self, device: str | BaseDevice) -> tuple[int, str]:
label = resolve_label(device)
if self.device_info is None:
raise RuntimeError("device_info not available — result was not created by simulate()")
for i, (lbl, _) in enumerate(self.device_info):
if lbl == label:
return i, label
available = [lbl for lbl, _ in self.device_info]
raise ValueError(f"Device '{label}' not found in device_info. Available: {available}")
[docs]
def state(self, t: float | None = None, *, dm: bool = False, frame: Frame = "integration") -> Any:
"""Return a kept state, optionally promoted to a density matrix.
Parameters
----------
t : float or None, optional
Saved time in ns. ``None`` selects the final state.
dm : bool, default=False
Promote a ket to a density matrix.
frame : {"integration", "lab"}, default="integration"
Frame of the returned state.
"""
s = (self._exported(self._raw_final_state(), len(self.times) - 1, frame) if t is None
else self.state_at(t, frame=frame))
if dm and self._backend.is_ket(s):
return self._backend.state_to_dm(s)
return s
[docs]
def state_at(self, t: Any, *, method: str = "exact", frame: Frame = "integration") -> Any:
"""Return a saved native state in captured product coordinates.
Saved states are in the integration frame of :attr:`frame`. With
``frame="lab"``, the method maps the state into the lab frame at its
saved time.
Parameters
----------
t : scalar
Saved time in ns.
method : {"exact", "nearest"}, default="exact"
Time-selection rule. The lookup never interpolates states.
frame : {"integration", "lab"}, default="integration"
Frame of the returned state.
"""
return self._exported(*self._saved_state(t, method), frame)
def _raw_state_at(self, t: Any, *, method: str = "exact") -> Any:
"""Return a kept state at a scalar time without state interpolation.
Parameters
----------
t : scalar
Query time in ns.
method : {"exact", "nearest"}, default="exact"
Time-selection rule.
"""
return self._saved_state(t, method)[0]
def _saved_state(self, t: Any, method: str) -> tuple[Any, Any]:
"""Return a kept native state and its time index."""
from quchip.results._time import require_valid, time_selection
from quchip.utils.jax_utils import contains_tracer
if method not in ("exact", "nearest"):
raise ValueError('State lookup method must be "exact" or "nearest".')
xp = self._backend.array_module
if xp.asarray(t).ndim != 0:
raise ValueError("state_at() requires a scalar time.")
index, _, _ = time_selection(self.times, t, method, xp)
if self._states is None or len(self._states) == 0:
if not contains_tracer(index) and int(index) != len(self.times) - 1:
raise RuntimeError(_NO_STATES_MSG)
return require_valid(self._raw_final_state(), index != len(self.times) - 1, _NO_STATES_MSG), index
if contains_tracer(index):
return self._stacked_states()[index], index
return self._states[int(index)], index
[docs]
def dm_at(self, t: float, *, method: str = "exact", frame: Frame = "integration") -> Any:
"""Return a density matrix at a kept time, and promote kets on demand.
Parameters
----------
t : float
Query time in ns.
method : {"exact", "nearest"}, default="exact"
Time-selection rule.
frame : {"integration", "lab"}, default="integration"
Frame of the returned state.
"""
state = self.state_at(t, method=method, frame=frame)
return self._backend.state_to_dm(state) if self._backend.is_ket(state) else state
@property
def final_state(self) -> Any:
"""Return the last kept state in captured product coordinates."""
return self._export_state(self._raw_final_state(), len(self.times) - 1)
def _raw_final_state(self):
if self._states is not None and len(self._states) > 0:
return self._states[-1]
if self._final_state is not None:
return self._final_state
raise RuntimeError('No final state available; run with states="all" or states="final" to retain it.')
def _final_projection(self, target: Any, *, amplitude: bool, frame: Frame) -> Any:
"""Project the final saved state on a target, without its history."""
backend = self._backend
state = self._raw_final_state()
is_ket = backend.is_ket(state)
if amplitude and not is_ket:
raise TypeError("Final amplitudes require ket states; use final_overlap_magnitudes for density matrices.")
stacked = backend.stack_states([state])
target, moving = self._resolve_target(target, frame, self.times[-1:])
if moving is not None:
return _moving_projection(backend, moving, stacked, amplitude=amplitude)[0]
value = backend.overlap_over_time(target, stacked)[0]
return value if amplitude else backend.array_module.abs(value) ** (2 if is_ket else 1)
[docs]
def iq_readout(self, output: Any, *, means: Any, frequency: Any, receiver: Any,
noise_frequencies: Any = None) -> Any:
"""Propagate conditional coherent boundary fields through captured output wiring.
means gives one noiseless complex field per outcome, in 1/sqrt(ns), at
the selected Markov boundary channel. A mapping gives fields at several
boundary channels. Unspecified fields are vacuum. The output line adds
its resolved gain, filter loss noise, and amplifier noise. The given
means, returned IQ, and receiver transfers follow the VNA engineering
convention. Conjugate ``output(...).raw_amplitude`` before you use its
stationary value as a boundary template. This stationary coherent-state
readout model excludes quantum-device correlations and occupied boundary
inputs. Use calibrated IQReadout distributions when those effects are
included in a detector calibration.
Parameters
----------
output : port object or str
External output reference plane.
means : array_like or mapping
Engineering-convention Markov-boundary means in ``1/sqrt(ns)``.
frequency : scalar
Carrier frequency in GHz.
receiver : IQReceiver
Boxcar receiver and optional digital transfer.
noise_frequencies : array_like or None, optional
Two-sided offsets in GHz. ``None`` uses the standard grid.
"""
if self._readout_wiring is None:
raise RuntimeError("No captured output wiring is available for this result.")
return self._readout_wiring.iq_readout(output, means=means, frequency=frequency,
receiver=receiver, noise_frequencies=noise_frequencies)
[docs]
def measure(self, *devices: Any, t: Any = None, basis: Any = "energy") -> Any:
"""Measure kept states in local energy bases, without further evolution.
Pass multiple devices for joint outcomes, t for an exact saved time, or
basis='solver'. Custom local unitary columns are expressed in the captured
energy basis of the stored integration frame. Give one matrix for one
device, or a device mapping. No phase-frame conversion is applied. Samples
at different times represent independently terminated experiments.
Parameters
----------
*devices : device object or str
Measured devices. An empty selection measures all devices.
t : scalar, array_like, or None, optional
Saved time or times in ns. ``None`` selects the final state.
basis : {"energy", "solver"}, array_like, or mapping, default="energy"
Captured local measurement basis.
"""
from quchip.results.terminal import measure_result
return measure_result(self, devices, t=t, basis=basis)
[docs]
def reduced_state(self, t: float, device: str | BaseDevice, *, frame: Frame = "integration") -> Any:
"""Partial-trace a saved state down to one device.
Parameters
----------
t : float
Saved time in ns.
device : device object or str
Device to keep.
frame : {"integration", "lab"}, default="integration"
Frame of the returned state.
"""
dev_idx, _ = self._resolve_device_idx(device)
state = self._in_frame(*self._saved_state(t, "exact"), frame)
if self._excitation_space is not None:
return self._excitation_space.reduced(state, self._backend, (dev_idx,))
return self._backend.ptrace(state, dev_idx, self.dims)
# ------------------------------------------------------------------
# Populations
# ------------------------------------------------------------------
@property
def populations(self) -> dict[tuple[int, ...], Any]:
"""Return per-basis-state populations ``|<n1, n2, ...|psi(t)>|**2`` over time.
Keys index the solver's product basis, which is not necessarily the
local energy basis. Each value is a native real array over
``self.times``. Use :meth:`population` for isolated energy-level
occupations.
This method requires ``states="all"``. For density-matrix trajectories,
it reads the DM diagonal at each timestep.
"""
backend = self._backend
if self._excitation_space is not None:
from math import prod
from quchip.utils.memory import require_memory
require_memory((256 + 8 * len(self.dims) + 16 * len(self.times)) * prod(self.dims),
task="Full product population dictionary",
remedy="Use population(device, level) instead.")
states = self._backend.stack_states(self.states)
values = backend.array_module.real(backend.populations_over_time(states))
return {label: values[:, i] for i, label in enumerate(self._basis_labels)}
basis_labels = self._basis_labels
# One batched diagonal read over the leading time axis -> (T, ∏dims),
# never building a per-timestep density matrix for ket trajectories.
all_diags = backend.array_module.real(backend.populations_over_time(self._stacked_states()))
return {label: all_diags[:, i] for i, label in enumerate(basis_labels)}
[docs]
def population(self, device: str | BaseDevice, level: int = 0) -> Any:
"""Return occupation of a captured isolated energy level in native arrays.
Parameters
----------
device : device object or str
Captured device.
level : int, default=0
Zero-based isolated energy level.
"""
backend = self._backend
xp = backend.array_module
dev_idx, label = self._resolve_device_idx(device)
dev_dim = self.dims[dev_idx]
if not (0 <= level < dev_dim):
raise ValueError(
f"Level {level} out of range for device '{label}' with {dev_dim} levels (0..{dev_dim - 1})."
)
if self._excitation_space is not None:
diags = backend.populations_over_time(self._stacked_states())
selected = self._excitation_space.levels[:, dev_idx] == level
return xp.real(diags @ xp.asarray(selected, dtype=float))
basis = self._bases.get(label)
if basis is not None and basis.energy_to_solver() is not None:
vector = xp.asarray(basis.energy_state(level))
projector = backend.from_array(xp.outer(vector, vector.conj()), dims=[[dev_dim], [dev_dim]])
embedded = backend.embed(projector, dev_idx, self.dims)
return xp.real(backend.expect_over_time(embedded, self._stacked_states()))
# Marginalize the other devices without enumerating product-basis labels.
diags = backend.populations_over_time(self._stacked_states())
axes = tuple(index + 1 for index in range(len(self.dims)) if index != dev_idx)
marginal = xp.sum(diags.reshape((len(diags), *self.dims)), axis=axes)
return xp.real(marginal @ xp.asarray(np.arange(dev_dim) == level, dtype=float))
# ------------------------------------------------------------------
# Plot shims — delegate to the (lazy) viz module
# ------------------------------------------------------------------
[docs]
def plot_populations(
self,
*,
trace_out: str | BaseDevice | list[str | BaseDevice] | None = None,
computational: bool = False,
ax: Any = None,
**kwargs: Any,
) -> Any:
"""Plot per-basis-state populations over time.
Parameters
----------
trace_out : device object, str, sequence, or None, optional
Devices to trace out.
computational : bool, default=False
Restrict labels to computational levels.
ax : matplotlib.axes.Axes or None, optional
Destination axes.
**kwargs
Forwarded to :func:`quchip.viz.plot_populations`.
"""
from quchip.viz.results import plot_populations
return plot_populations(self, trace_out=trace_out, computational=computational, ax=ax, **kwargs)
[docs]
def plot_state(
self,
index: int,
*,
trace_out: str | BaseDevice | list[str | BaseDevice] | None = None,
computational: bool = False,
mode: StateMode = "population",
ax: Any = None,
**kwargs: Any,
) -> Any:
"""Plot one stored state as populations or a density matrix.
Parameters
----------
index : int
Saved-state index.
trace_out : device object, str, sequence, or None, optional
Devices to trace out.
computational : bool, default=False
Restrict labels to computational levels.
mode : {"population", "density_matrix"}, default="population"
Plot representation.
ax : matplotlib.axes.Axes or None, optional
Destination axes.
**kwargs
Forwarded to :func:`quchip.viz.plot_state`.
"""
from quchip.viz.results import plot_state
return plot_state(
self, index, trace_out=trace_out, computational=computational, mode=mode, ax=ax, **kwargs
)
[docs]
def plot_expectation(self, *, keys: list[Any] | None = None, ax: Any = None, **kwargs: Any) -> Any:
"""Plot expectation-value traces over time.
Parameters
----------
keys : list or None, optional
Observable keys. ``None`` plots every captured trace.
ax : matplotlib.axes.Axes or None, optional
Destination axes.
**kwargs
Forwarded to :func:`quchip.viz.plot_expectation`.
"""
from quchip.viz.results import plot_expectation
return plot_expectation(self, keys=keys, ax=ax, **kwargs)
[docs]
def plot_wigner(
self,
index: int = -1,
*,
trace_out: str | BaseDevice | list[str | BaseDevice] | None = None,
ax: Any = None,
**kwargs: Any,
) -> Any:
"""Plot the Wigner function of one stored state.
Parameters
----------
index : int, default=-1
Saved-state index.
trace_out : device object, str, sequence, or None, optional
Devices to trace out before plotting.
ax : matplotlib.axes.Axes or None, optional
Destination axes.
**kwargs
Forwarded to :func:`quchip.viz.plot_wigner`.
"""
from quchip.viz.results import plot_wigner
return plot_wigner(self, index, trace_out=trace_out, ax=ax, **kwargs)
[docs]
def check_truncation(self, *, threshold: float = 1e-3) -> dict[Any, Any]:
"""Deprecated method form of :func:`quchip.check_truncation`.
quchip 0.6 removes this method. Call ``quchip.check_truncation(result)``.
Parameters
----------
threshold : float, default=1e-3
Maximum accepted boundary population.
"""
from quchip.engine.truncation import check_truncation
from quchip.utils.deprecation import warn_renamed
warn_renamed(f"{type(self).__name__}.check_truncation()", "quchip.check_truncation(result)", removal="0.6")
return check_truncation(self, threshold=threshold)
def __repr__(self) -> str:
t_min = float(self.times[0]) if len(self.times) > 0 else 0.0
t_max = float(self.times[-1]) if len(self.times) > 0 else 0.0
parts = [
f"SimulationResult(solver={self.solver!r}",
f"t=[{t_min:.1f}, {t_max:.1f}] ns",
f"steps={len(self.times)}",
f"dims={self.dims}",
]
if self._expect_data is not None:
parts.append(f"expect=dict({len(self._expect_data)} keys)")
if self._output_data:
parts.append(f"outputs=dict({len(self._output_data)} keys)")
return ", ".join(parts) + ")"
[docs]
class SimulationBatchResult(BatchResult[SimulationResult]):
"""Ordered, immutable batch of :class:`SimulationResult` with stacked helpers.
Returned by :func:`~quchip.engine.solve_many` and by any sweep that solves
many problems in one call. The batch keeps the iteration order, so each
per-element result maps one-to-one onto the input that produced it.
The ``final_*`` helpers stack along a new leading batch axis in the
backend's array module. The grid-aware :meth:`expect` / :meth:`population`
accept ``reduce='last'`` for a final-value slice, so a loss function that
sums over the batch stays JAX-traceable end-to-end.
Attributes
----------
results : tuple of SimulationResult
Per-point results in sweep order.
shape : tuple of int
Sweep-grid shape.
axes : tuple
Named sweep-axis metadata.
"""
[docs]
def measure(self, *devices: Any, t: Any = None, basis: Any = "energy") -> Any:
"""Measure kept states in local energy bases, without further evolution.
Pass multiple devices for joint outcomes, t for an exact saved time, or
basis='solver'. Custom local unitary columns are expressed in the captured
energy basis of the stored integration frame. Give one matrix for one
device, or a device mapping. No phase-frame conversion is applied. Samples
at different times represent independently terminated experiments.
Parameters
----------
*devices : device object or str
Measured devices. An empty selection measures all devices.
t : scalar, array_like, or None, optional
Saved time or times in ns. ``None`` selects final states.
basis : {"energy", "solver"}, array_like, or mapping, default="energy"
Captured local measurement basis.
"""
from quchip.results.terminal import measure_result
return measure_result(self, devices, t=t, basis=basis)
def _require_shared_times(self, values: Any) -> Any:
from quchip.results._time import require_valid
if not self._results:
raise RuntimeError("Empty batch has no time coordinates.")
xp = self.backend.array_module
first = self._results[0].times
mismatch = xp.asarray(False)
message = "Batch points have different time grids; inspect individual results or reduce each trace."
for result in self._results[1:]:
if result.times.shape != first.shape:
raise ValueError(message)
mismatch = mismatch | xp.any(result.times != first)
return require_valid(values, mismatch, message)
@property
def times(self) -> Any:
"""Return shared native coordinates, or tell why time grids are incompatible."""
return self._require_shared_times(self._results[0].times if self._results else None)
def _trace_values(self, values: list[Any], reduce: str | None) -> Any:
if reduce is None:
values = self._require_shared_times(values)
return self._reshape([self._reduce_time_axis(value, reduce) for value in values])
def _check_targets_len(self, targets: list[Any] | tuple[Any, ...]) -> None:
if len(targets) != len(self._results):
raise ValueError(f"Expected {len(self._results)} targets, got {len(targets)}.")
def _reduce_time_axis(self, values: Any, reduce: str | None) -> Any:
if reduce is None:
return values
xp = self.backend.array_module
if reduce == "last":
return values[..., -1]
if reduce == "max":
return xp.max(values, axis=-1)
if reduce == "mean":
return xp.mean(values, axis=-1)
raise ValueError("reduce must be one of None, 'last', 'max', or 'mean'.")
[docs]
def expect(self, key: Any, index: int | None = None, *, reduce: str | None = None) -> Any:
"""Return expectation traces on the natural sweep grid.
Parameters
----------
key : object or str
Captured observable key.
index : int or None, optional
Entry of a list-valued observable.
reduce : {None, "last", "max", "mean"}, optional
Reduction along the time axis.
"""
return self._trace_values([r.expect(key, index=index) for r in self._results], reduce)
[docs]
def output(self, exposure: Any) -> OutputFieldTrace:
"""Return one complete field trace on the natural sweep grid.
Parameters
----------
exposure : port object or str
External output reference plane.
"""
traces = [result.output(exposure) for result in self._results]
if not traces:
raise RuntimeError("Empty batch has no output fields.")
times, amplitude, flux, raw_amplitude, raw_flux = self._require_shared_times((
traces[0].times, [trace.amplitude for trace in traces], [trace.photon_flux for trace in traces],
[trace.raw_amplitude for trace in traces], [trace.raw_photon_flux for trace in traces],
))
return OutputFieldTrace(
exposure=traces[0].exposure,
times=times,
amplitude=self._reshape(amplitude),
photon_flux=self._reshape(flux),
raw_amplitude=self._reshape(raw_amplitude),
raw_photon_flux=self._reshape(raw_flux),
)
[docs]
def population(self, device: str | BaseDevice, level: int = 0, *, reduce: str | None = None) -> Any:
"""Return population traces on the natural sweep grid.
Parameters
----------
device : device object or str
Captured device.
level : int, default=0
Zero-based isolated energy level.
reduce : {None, "last", "max", "mean"}, optional
Reduction along the time axis.
"""
return self._trace_values([r.population(device, level) for r in self._results], reduce)
[docs]
def jump_rate(self, key: Any, *, reduce: str | None = None) -> Any:
"""Return the jump-rate traces of one channel on the natural sweep grid.
``reduce`` accepts ``None``, ``"last"``, ``"max"``, or ``"mean"``
and acts on the time axis.
Parameters
----------
key : channel object or str
Resolved channel key.
reduce : {None, "last", "max", "mean"}, optional
Reduction along the time axis.
"""
return self._trace_values([r.jump_rate(key) for r in self._results], reduce)
[docs]
def collapse_flux(self, key: Any, *, reduce: str | None = None) -> Any:
"""Deprecated alias for :meth:`jump_rate`.
Parameters
----------
key : channel object or str
Resolved channel key.
reduce : {None, "last", "max", "mean"}, optional
Reduction along the time axis.
"""
from quchip.utils.deprecation import warn_renamed
warn_renamed("collapse_flux()", "jump_rate()")
return self.jump_rate(key, reduce=reduce)
[docs]
def collapse_integral(self, key: Any, *, reduce: str | None = None) -> Any:
"""Return the cumulative jump counts of one channel on the natural sweep grid.
``reduce`` accepts ``None``, ``"last"``, ``"max"``, or ``"mean"``
and acts on the time axis. ``reduce="last"`` returns the expected
number of jumps in each solve.
Parameters
----------
key : channel object or str
Resolved channel key.
reduce : {None, "last", "max", "mean"}, optional
Reduction along the time axis.
"""
return self._trace_values([r.collapse_integral(key) for r in self._results], reduce)
def _final_projections(self, targets: list[Any] | tuple[Any, ...], *, amplitude: bool, frame: Frame) -> Any:
self._check_targets_len(targets)
return self._stack([
result._final_projection(target, amplitude=amplitude, frame=frame)
for result, target in zip(self._results, targets)
])
[docs]
def final_overlap_magnitudes(self, targets: list[Any] | tuple[Any, ...], *, frame: Frame = "integration") -> Any:
"""Return final target-state populations without histories.
Targets and ``frame`` are as in :meth:`SimulationResult.overlap`.
Parameters
----------
targets : sequence of state_like, mapping, or str
One target ket or label per batch result.
frame : {"integration", "lab"}, default="integration"
Frame in which a ket target is fixed.
"""
return self._final_projections(targets, amplitude=False, frame=frame)
[docs]
def final_amplitudes(self, targets: list[Any] | tuple[Any, ...], *, frame: Frame = "integration") -> Any:
"""Return final complex ket amplitudes without histories.
Targets and ``frame`` are as in :meth:`SimulationResult.amplitude_array`.
Parameters
----------
targets : sequence of state_like, mapping, or str
One target ket or label per batch result.
frame : {"integration", "lab"}, default="integration"
Frame in which a ket target is fixed.
"""
return self._final_projections(targets, amplitude=True, frame=frame)
# ---------------------------------------------------------------------------
# Result wrapping helpers
# ---------------------------------------------------------------------------
[docs]
def wrap_solver_result(solver_result: SolverResult, problem: SolveProblem, backend: Backend, *,
label_targets: Any = None) -> SimulationResult:
"""Wrap a raw backend :class:`SolverResult` into a user-facing :class:`SimulationResult`.
The engine-side :class:`~quchip.engine.ir.SolveProblem` carries the
metadata needed to rebuild dict-form observables
(:func:`~quchip.engine.observables.build_observable_traces`) and to label
devices for partial-trace helpers.
Parameters
----------
solver_result : SolverResult
Raw backend solve output.
problem : SolveProblem
Frozen request carrying result metadata.
backend : Backend
Backend that owns the native states and arrays.
label_targets : LabelTargets or None, optional
Record captured when the solve finished. None captures it now.
"""
if label_targets is None:
label_targets = capture_label_targets(problem, {})
if solver_result.native is not None:
from quchip.results.trajectories import TrajectoryResult
return TrajectoryResult(solver_result.native, problem, label_targets=label_targets)
from quchip.engine.truncation import boundary_traces
plan = problem.truncation
samples = None
if plan is not None and plan.sampled:
raw = solver_result.expect
flat = list(raw.values()) if isinstance(raw, dict) else list(raw or ())
count = len(plan.operators)
diagnostic = flat[-count:] if count else ()
user_values = flat[:-count] if count else flat
samples = boundary_traces(plan, diagnostic, problem.tlist, backend)
solver_result = replace(solver_result, expect=user_values or None)
engine_result = problem.engine_result
observable_traces = None
output_traces = None
if problem.e_ops_meta is not None:
from quchip.engine.observables import build_observable_traces
observable_traces, output_traces = build_observable_traces(
solver_result,
problem.tlist,
dict_meta=problem.e_ops_meta,
resolved_frame=problem.resolved_frame,
engine_result=engine_result,
)
from quchip.results.terminal import ReadoutWiring
result = SimulationResult(
solver_result=solver_result,
backend=backend,
dims=engine_result.dims if engine_result.excitation_space is None else engine_result.excitation_space.dims,
excitation_space=engine_result.excitation_space,
device_info=problem.device_info,
observable_traces=observable_traces,
output_traces=output_traces,
channels=engine_result.slh.channels if engine_result.dissipation else (),
bases=engine_result.bases,
dissipation=engine_result.dissipation,
readout_wiring=ReadoutWiring.capture(engine_result.slh, backend.array_module),
frame=problem.resolved_frame,
label_targets=label_targets,
)
result._truncation = plan
result._boundary_traces = samples
result.stats["states"] = problem.states
result.stats["excitation_reduction"] = engine_result.metadata.get("excitation_reduction")
return result
[docs]
def wrap_solver_results_from_batch(
solver_results: list[SolverResult],
batch: SolveBatch,
backend: Backend,
) -> list[SimulationResult]:
"""Wrap backend results with each batch point's resolved solve context."""
if len(solver_results) != batch.batch_size:
raise RuntimeError(f"Backend returned {len(solver_results)} results for {batch.batch_size} batch points.")
captured: dict[int, list[tuple[Any, Any, Any]]] = {}
return [
wrap_solver_result(solver_result, problem, backend, label_targets=capture_label_targets(problem, captured))
for solver_result, problem in zip(solver_results, batch.problems)
]
[docs]
def capture_label_targets(problem: SolveProblem, captured: dict[int, list[tuple[Any, Any, Any]]]) -> Any:
"""Capture the label-target record of a solve, once for each chip and approximation of a batch.
Parameters
----------
problem : SolveProblem
Finished solve request.
captured : dict
``(chip, approximation, record)`` entries already captured for this batch, by chip identity.
The function adds new ones.
"""
from quchip.engine.states import LabelTargets
if problem.chip is None:
return None
approximation = problem.engine_result.approximation
entries = captured.setdefault(id(problem.chip), [])
for chip, known, record in entries:
if chip is problem.chip and known == approximation:
return record
record = LabelTargets.capture(problem.chip, approximation)
entries.append((problem.chip, approximation, record))
return record
def _frame_choice(frame: Any) -> str:
if not isinstance(frame, str) or frame not in ("integration", "lab"):
raise ValueError(f'frame must be "integration" or "lab"; got {frame!r}.')
return frame
def _moving_projection(backend: Backend, targets: Any, stacked: Any, *, amplitude: bool) -> Any:
"""Project stacked states on one target ket per time.
Kets give ``⟨v(t)|ψ(t)⟩``, or its squared magnitude when ``amplitude`` is
False. Density matrices give ``⟨v(t)|ρ(t)|v(t)⟩``.
"""
xp = backend.array_module
states = xp.asarray(backend.to_array(stacked), dtype=complex)
targets = xp.asarray(targets, dtype=complex)
if backend._is_ket_stack(states):
values = xp.einsum("ti,ti->t", xp.conj(targets), states[..., 0])
return values if amplitude else xp.abs(values) ** 2
return xp.abs(xp.einsum("ti,tij,tj->t", xp.conj(targets), states, targets))