Source code for quchip.engine.pulse_space

"""Invariant excitation spaces for deterministic evolution.

Coordinates are product energy labels. Support proofs ignore scalar values;
unknown support never supplies an exact reduction.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from functools import cached_property
from math import prod
from typing import Any

import numpy as np

from quchip.utils.memory import dense_bytes, level_table_bytes, require_memory
from quchip.utils.jax_utils import concrete_array_module, contains_tracer


# Largest relative weight of a supplied state outside an excitation cutoff that
# counts as basis-conversion round-off. Larger weight raises instead of being dropped.
_CUTOFF_RESIDUE = 1e-20


_OPERATOR_REMEDY = "Reduce the model, the initial excitation support, or an excitation cutoff's max_total."


def _require_inside(cutoff, outside, total, source):
    """Raise when a supplied state has more than round-off weight outside the cutoff."""
    if cutoff is not None and outside > _CUTOFF_RESIDUE * total:
        raise ValueError(
            f"The {source} has weight {outside / total:.3g} on labels with N > {cutoff}, outside the "
            f"excitation cutoff. quchip treats weight below {_CUTOFF_RESIDUE:g} as basis-conversion round-off, "
            "drops it and does not renormalize. "
            "Prepare the state inside the cutoff, for example with chip.compact_state(), or raise max_total."
        )


[docs] @dataclass(frozen=True, eq=False) class PulseSpace: dims: tuple[int, ...] labels: tuple[str, ...] totals: tuple[int, ...] levels: np.ndarray bases: Any
[docs] @classmethod def create(cls, dims, labels, totals, bases): from quchip.engine.excitation import sector_levels, sector_size totals = tuple(sorted(set(totals))) size = sum(sector_size(dims, total) for total in totals) require_memory(level_table_bytes(size, len(dims)), task=f"Exact pulse space (dimension {size})", remedy=_OPERATOR_REMEDY) levels = np.concatenate([sector_levels(tuple(dims), n) for n in totals]) levels.flags.writeable = False return cls(tuple(dims), tuple(labels), totals, levels, dict(bases))
@property def size(self): return len(self.levels) @property def solver_dims(self): return (self.size,) @cached_property def rows(self): return {tuple(label): index for index, label in enumerate(self.levels)}
[docs] def entries(self, backend, operator, support): """Return the rows, columns and values of a resolved local operator on this space.""" from quchip.engine.assembly import _energy_operator from quchip.engine.sectors import _level_pairs support = tuple(support) or tuple(range(len(self.dims))) matrix = _energy_operator(operator, tuple(self.labels[axis] for axis in support), self.bases, backend) rows, columns, local_rows, local_columns = _level_pairs(self.dims, support, self.levels, self.levels) return rows, columns, matrix[local_rows, local_columns]
[docs] def embed(self, backend, operator, support): """Gather a resolved local operator without a product-space allocation. The backend chooses the storage of the result. """ return backend.compact_operator(*self.entries(backend, operator, support), self.size)
[docs] def authored(self, backend, operator, support): """Lower a factored authored sum directly into energy coordinates.""" from quchip.declarative.expr import PhysicsExpr from quchip.engine.assembly import _local_products if not isinstance(operator, PhysicsExpr): return None labels = tuple(self.labels[i] for i in support) if support else self.labels terms = _local_products(operator, labels, self.bases) if terms is None: return None return backend.compact_operator(*self._authored_entries(terms, labels), self.size)
def _authored_entries(self, terms, labels): """Return the rows, columns and values of a sum of local products, with repeated entries.""" from quchip.engine.sectors import _level_pairs xp = concrete_array_module(terms, tuple(self.bases[label].energy_vectors for label in labels)) rows, columns, values = [np.zeros(0, dtype=int)], [np.zeros(0, dtype=int)], [xp.zeros(0, dtype=complex)] for coefficient, factors in terms: axes = tuple(sorted(self.labels.index(label) for label in factors)) if not axes: diagonal = np.arange(self.size) rows.append(diagonal) columns.append(diagonal) values.append(xp.full(self.size, coefficient, dtype=complex)) continue local = xp.ones((1, 1), dtype=complex) for axis in axes: vectors = xp.asarray(self.bases[self.labels[axis]].energy_vectors) local = xp.kron(local, xp.conj(vectors.T) @ xp.asarray(factors[self.labels[axis]]) @ vectors) pair_rows, pair_columns, local_rows, local_columns = _level_pairs(self.dims, axes, self.levels, self.levels) rows.append(pair_rows) columns.append(pair_columns) values.append(coefficient * local[local_rows, local_columns]) return np.concatenate(rows), np.concatenate(columns), xp.concatenate(values)
[docs] def lift(self, state, backend): """Expand a state into captured solver coordinates, after a memory check.""" matrix = backend.to_array(state) dimension = prod(self.dims) ket = matrix.shape[-1] == 1 require_memory( dense_bytes(*((dimension,) if ket else (dimension, dimension)), copies=4), task="Full pulse state export", remedy="Use compact_state(), population(), or reduced_state() instead.", ) xp = backend.array_module # Build only the isometry columns present in the compact space. require_memory( dense_bytes(dimension, self.size, copies=3), task="Pulse state coordinate map", remedy="Use compact_state() or reduced_state() instead.", ) columns = [] for levels in self.levels: column = xp.ones(1, dtype=complex) for label, level in zip(self.labels, levels): column = xp.kron(column, xp.asarray(self.bases[label].energy_state(int(level)))) columns.append(column) transform = xp.stack(columns, axis=1) result = transform @ xp.asarray(matrix) if not ket: result = result @ xp.conj(transform.T) return backend.from_array(result, dims=[list(self.dims), [1] * len(self.dims) if ket else list(self.dims)])
[docs] def project(self, state, backend): """Project an already allocated native solver state into these coordinates.""" xp = backend.array_module matrix = xp.asarray(backend.to_array(state)) tensor = matrix.reshape((*self.dims, *((1,) if matrix.shape[-1] == 1 else self.dims))) for axis, label in enumerate(self.labels): vectors = self.bases[label].energy_to_solver() if vectors is not None: tensor = xp.moveaxis(xp.tensordot(xp.conj(xp.asarray(vectors).T), tensor, axes=([1], [axis])), 0, axis) if matrix.shape[-1] != 1: for axis, label in enumerate(self.labels, len(self.dims)): vectors = self.bases[label].energy_to_solver() if vectors is not None: tensor = xp.moveaxis(xp.tensordot(xp.asarray(vectors).T, tensor, axes=([1], [axis])), 0, axis) indices = np.ravel_multi_index(tuple(self.levels.T), self.dims) energy = tensor.reshape(matrix.shape) result = energy[indices] if matrix.shape[-1] != 1: result = result[:, indices] return backend.from_array(result, dims=[[self.size], [1] if matrix.shape[-1] == 1 else [self.size]])
[docs] def reduced(self, state, backend, axes): """Trace out spectators directly, preserving inter-sector coherence.""" axes = tuple(axes) xp = backend.array_module matrix = xp.asarray(backend.to_array(state)) density = matrix @ xp.conj(matrix.T) if matrix.shape[-1] == 1 else matrix outside = [i for i in range(len(self.dims)) if i not in axes] rows, columns = np.nonzero(np.all(self.levels[:, None, outside] == self.levels[None, :, outside], axis=-1)) dims = tuple(self.dims[i] for i in axes) indices = np.ravel_multi_index(tuple(self.levels[:, axes].T), dims) result = xp.zeros((prod(dims), prod(dims)), dtype=complex) if xp is np: np.add.at(result, (indices[rows], indices[columns]), density[rows, columns]) else: result = result.at[indices[rows], indices[columns]].add(density[rows, columns]) transform = xp.ones((1, 1), dtype=complex) for axis in axes: vectors = self.bases[self.labels[axis]].energy_to_solver() transform = xp.kron(transform, xp.eye(self.dims[axis]) if vectors is None else xp.asarray(vectors)) result = transform @ result @ xp.conj(transform.T) return backend.from_array(result, dims=[list(dims), list(dims)])
[docs] def initial_totals(chip, state, resolution, cutoff=None): """Return the excitation totals that a given initial state occupies, or ``None`` when unknown. The planner places labels and an omitted state, which are dressed states. With ``cutoff``, a state with more than round-off weight on totals above it raises, and only totals up to the cutoff are returned. """ from quchip.engine.states import CompactState, materialize_state_spec if isinstance(state, CompactState): if state.space.labels != tuple(d.label for d in chip.devices) or state.space.dims != resolution.dims: raise ValueError("Compact state devices and dimensions do not match this chip.") if cutoff is not None and max(state.space.totals) > cutoff: raise ValueError( f"The compact state holds totals {list(state.space.totals)}, outside the excitation cutoff " f"N <= {cutoff}. Prepare it for this chip with chip.compact_state()." ) return set(state.space.totals) if contains_tracer(state): return None # Native state support is known only for concrete captured arrays. native = materialize_state_spec(chip, state, resolution.bases) if contains_tracer(native): return None dims = resolution.dims matrix = np.asarray(chip.backend.to_array(native)) ket = matrix.shape[-1] == 1 tensor = matrix.reshape((*dims, *((1,) if ket else dims))) for axis, device in enumerate(chip.devices): vectors = resolution.bases[device.label].energy_to_solver() if vectors is not None: tensor = np.moveaxis(np.tensordot(np.asarray(vectors).conj().T, tensor, axes=([1], [axis])), 0, axis) if not ket: for axis, device in enumerate(chip.devices, len(dims)): vectors = resolution.bases[device.label].energy_to_solver() if vectors is not None: tensor = np.moveaxis(np.tensordot(np.asarray(vectors).T, tensor, axes=([1], [axis])), 0, axis) energy = tensor.reshape(matrix.shape) occupied = np.any(energy != 0, axis=1) if not ket: occupied |= np.any(energy != 0, axis=0) coordinates = np.asarray(np.unravel_index(np.flatnonzero(occupied), dims)) totals = set(coordinates.sum(axis=0).tolist()) if cutoff is not None: weights = np.abs(energy[:, 0]) ** 2 if ket else np.abs(np.real(np.diagonal(energy))) level_sums = np.asarray(np.unravel_index(np.arange(weights.size), dims)).sum(axis=0) _require_inside(cutoff, float(np.sum(weights[level_sums > cutoff])), float(np.sum(weights)), "initial state") totals = {n for n in totals if n <= cutoff} return totals
[docs] def plan_pulse_space(chip, operations, states, resolution, approximation, dissipation, *, whole=False): """Prove a union of total-index sectors invariant under the complete generator. Returns the space, the reason, and the boundary totals. Under an active excitation cutoff ``K``, every step of the closure stays inside ``[0, K]``, unknown support uses every label with ``N <= K``, and the product space is never used. ``whole`` selects that cutoff space after the initial states are checked. The boundary holds the retained totals that a generator change can move above ``K``, or is ``None`` when the changes are unknown or no cutoff is active. """ from quchip.chip.ports import Port from quchip.cutoff import active_cutoff from quchip.engine.assembly import _build_delivered_signals, _component_authored_terms from quchip.engine.excitation import ( authored_excitation_changes, drive_excitation_changes, excitation_structure, keeps_zero_total_only, sector_size, ) from quchip.engine.ir import CoherentOp from quchip.engine.states import label_levels bases, dims = resolution.bases, resolution.dims labels = tuple(d.label for d in chip.devices) policy = active_cutoff(chip) cutoff = None if policy is None else policy.max_total structure = excitation_structure(chip, approximation, resolution=resolution) def fallback(reason): if cutoff is None: return None, reason, None space = PulseSpace.create(dims, labels, range(cutoff + 1), bases) return space, f"{reason} The solve keeps every label with N <= {cutoff}.", None occupied = set() for state in states: if state is None or isinstance(state, (Mapping, str)): # A label is the dressed state that the static model assigns to it. total = 0 if state is None else sum(label_levels(chip, state, dims)) if cutoff is not None: if total > cutoff: raise ValueError( f"The initial label has total excitation {total}, outside the excitation cutoff N <= {cutoff}." ) # Under a cutoff, the dressed state spans the totals of its class. occupied.update(next(group for group in structure.classes(cutoff) if total in group)) elif not structure.conserves: return None, "The dressed initial state has no proven finite excitation support.", None else: occupied.add(total) continue totals = initial_totals(chip, state, resolution, cutoff) if totals is None and cutoff is not None: raise ValueError( "The support of the traced initial state is unknown, so quchip cannot prove that it lies inside " f"the excitation cutoff N <= {cutoff}. Pass chip.compact_state(...) or a label mapping instead." ) if totals is None or not totals: return None, "Initial excitation support is unknown.", None occupied.update(totals) changes = {0} for support in structure.interaction_changes(): if support is None: return fallback("Static interaction support is unknown.") changes.update(support) changes.update(-n for n in support) for operator, _coefficient, support, _owner, _origin, _tag in _component_authored_terms( chip, resolution, chip.backend ): if len(support) == 2 and keeps_zero_total_only(approximation): continue support_changes = authored_excitation_changes(operator, tuple(labels[i] for i in support), chip.backend, bases) if support_changes is None: return fallback("Intrinsic time-dependent support is unknown.") changes.update(support_changes) changes.update(-n for n in support_changes) for delivered in _build_delivered_signals(chip, list(operations)).values(): weights = drive_excitation_changes(chip, delivered.drive, delivered.target, bases, approximation) if weights is None: return fallback("Delivered drive support is unknown.") changes.update(weights) changes.update(-n for n in weights) # A jump and its no-jump product have separate support obligations. Network # scattering and coherent inputs combine port channels only. coherent = any(isinstance(op, CoherentOp) for op in operations) ports = set() for operator, _rate, support, *_, owner in chip._collapse_contributions_with_owners(bases): port = isinstance(owner, Port) if not (dissipation or port and (coherent or resolution.network is not None)): continue weights = authored_excitation_changes( operator, tuple(labels[i] for i in support) or labels, chip.backend, bases ) if weights is None: return fallback("Collapse operator support is unknown.") if dissipation: changes.update(weights) changes.update(a - b for a in weights for b in weights) if port: ports.update(weights) if resolution.network is not None: # Mixed port channels put their cross products in H and L†L. changes.update(a - b for a in ports for b in ports) if dissipation and any( field.input_occupation is not None for field in chip.port_network._field_channels(resolution.network) ): changes.update(-n for n in ports) if coherent: changes.update(ports) changes.update(-n for n in ports) maximum = sum(d - 1 for d in dims) if cutoff is None else cutoff if whole: occupied = set(range(maximum + 1)) while True: reached = occupied | {n - change for n in occupied for change in changes if 0 <= n - change <= maximum} if reached == occupied: # Native solver algebras treat 1x1 QArrays/Qobjs as scalars. Add # another physical sector and prove closure again, never a dummy level. if sum(sector_size(dims, n) for n in reached) == 1 and maximum > 0: occupied.add(next(n for n in range(maximum + 1) if n not in occupied)) continue break occupied = reached size = sum(sector_size(dims, n) for n in occupied) if cutoff is not None: # Changes are column minus row, so a negative change raises the total. boundary = tuple(sorted(n for n in occupied if any(n - change > cutoff for change in changes))) reason = (f"Every label with N <= {cutoff} is kept." if whole else "The initial support and the truncated generator close inside the excitation cutoff.") return PulseSpace.create(dims, labels, occupied, bases), reason, boundary if size == prod(dims): return None, "The proven generator closure reaches the full product space.", None if not chip.backend.compact_operators_fit(size, 8): return None, f"The backend's compact operators on {size} states do not fit in available memory.", None reason = "The initial support and complete generator close exactly." return PulseSpace.create(dims, labels, occupied, bases), reason, None