Source code for pymablock.block_diagonalization

"""Algorithms for quasi-degenerate perturbation theory."""

from collections.abc import Callable, Sequence
from copy import copy
from functools import reduce
from inspect import signature
from operator import matmul, mul
from typing import Any
from warnings import warn

import numpy as np
import sympy
from scipy import sparse
from scipy.spatial import KDTree
from sympy.physics.quantum import Dagger, Operator

from pymablock import second_quantization
from pymablock.algorithm_parsing import series_computation
from pymablock.algorithms import main, nonhermitian
from pymablock.kpm import greens_function, rescale
from pymablock.linalg import (
    ComplementProjector,
    aslinearoperator,
    direct_greens_function,
    is_diagonal,
)
from pymablock.number_ordered_form import (
    NumberOrderedForm,
    find_operators,
    generator_types,
)
from pymablock.series import (
    BlockSeries,
    zero,
)

__all__ = ["block_diagonalize", "operator_to_BlockSeries"]

# Common types
SingleSubspaceBasis = np.ndarray | sympy.MatrixBase | sparse.spmatrix | sparse.sparray
SubspaceBasis = SingleSubspaceBasis | tuple[SingleSubspaceBasis, SingleSubspaceBasis]
DirectSubspaceBasis = np.ndarray | tuple[np.ndarray, np.ndarray]
Eigenvectors = tuple[SubspaceBasis, ...]


### The main function for end-users.
[docs] def block_diagonalize( hamiltonian: list | dict | BlockSeries | sympy.Matrix | sympy.Expr, *, solve_sylvester: Callable | None = None, subspace_eigenvectors: Eigenvectors | None = None, subspace_indices: tuple[int, ...] | np.ndarray | None = None, direct_solver: bool = True, solver_options: dict | None = None, symbols: sympy.Symbol | Sequence[sympy.Symbol] | None = None, atol: float = 1e-12, fully_diagonalize: ( tuple[int, ...] | dict[int, np.ndarray | sympy.Matrix | sympy.Expr] | np.ndarray | sympy.Matrix | sympy.Expr ) = (), hermitian: bool = True, ) -> tuple[BlockSeries, BlockSeries, BlockSeries]: r"""Find the block diagonalization of a Hamiltonian order by order. This uses a generalization of quasi-degenerate perturbation theory known as Lowdin perturbation theory, Schrieffer-Wolff transformation, or van Vleck transformation to the case of multiple blocks. Some blocks of the resulting Hamiltonian can be fully diagonalized, reproducing the usual Rayleigh-Schrodinger perturbation theory. Alternatively, the algorithm can perturbatively eliminate any subset of the off-diagonal elements of the Hamiltonian. This function does not yet perform the computation. Instead, it defines the computation as a `~pymablock.series.BlockSeries` object, which can be evaluated at any order. This function accepts a Hamiltonian in several formats and it first brings it to the eigenbasis of the unperturbed Hamiltonian if the blocks, eigenvectors or indices of the eigenvalues are provided, see below. For large numerical calculations with a sparse Hamiltonian and a low dimensional relevant subspace, the algorithm uses an implicit representation of the spectrum and does not require full diagonalization. This is enabled if ``subspace_eigenvectors`` do not span the full space of the unperturbed Hamiltonian. In the non-Hermitian case, the implicit method uses the same direct-solver machinery but may require distinct left and right subspace vectors. Parameters ---------- hamiltonian : Full symbolic or numeric Hamiltonian to block diagonalize. The Hamiltonian is normalized to a `~pymablock.series.BlockSeries`. Supported formats: - A list, of the form ``[h_0, h_1, h_2, ...]`` where ``h_0`` is the unperturbed Hamiltonian and ``h_1, h_2, ...`` are the first order perturbations. The elements ``h_i`` may be `~sympy.matrices.dense.Matrix`, `~numpy.ndarray`, `~scipy.sparse.spmatrix`, that require separating into blocks. Otherwise, ``h_i`` may be a list of lists with the Hamiltonian blocks. - A dictionary, of the form ``{(0, 0): h_0, (1, 0): h_1, (0, 1): h_2}``, or ``{1: h_0, x: h_1, y: h_2}`` for symbolic Hamiltonians. In the former case, the keys must be tuples of integers indicating the order of each perturbation. In the latter case, the keys must be monomials and the indices are ordered as in `H.dimension_names`. The values of the dictionary, ``h_i`` have the same format as in the list case. - A `~sympy.matrices.dense.Matrix`, unless a list of ``symbols`` is provided as perturbative parameters, all symbols will be treated as perturbative. The normalization to `~pymablock.series.BlockSeries` is done by Taylor expanding in ``symbols`` to the desired order. - A `~pymablock.series.BlockSeries`, used directly. - A single `~sympy.core.expr.Expr` containing the second-quantized Hamiltonian. solve_sylvester : A function that solves the Sylvester equation. If not provided, it is selected automatically based on the inputs. Custom solvers should accept ``solve_sylvester(Y, index)``. The legacy one-argument form ``solve_sylvester(Y)`` is deprecated and will be removed in version 2.4.0. subspace_eigenvectors : A tuple describing the subspaces onto which the Hamiltonian is projected and separated into blocks. Each entry may be either a single basis ``V`` or, for non-Hermitian problems, a pair ``(R, L)`` of right and left basis vectors. A single basis is shorthand for ``(V, V)``, i.e. identical left and right subspaces. The combined explicit subspaces must satisfy ``L^\dagger R = I``. The non-Hermitian pair format ``(R, L)`` is only supported when ``hermitian=False``. If None, the unperturbed Hamiltonian must be block diagonal. If some vectors are missing, the implicit method is used. Mutually exclusive with ``subspace_indices``. If neither ``subspace_eigenvectors`` nor ``subspace_indices`` are provided, the BlockSeries is defined with a single block. subspace_indices : An array indicating which state belongs to which subspace. If there are two blocks, the labels are 0 for the A (effective) subspace and 1 for the B (auxiliary) subspace. Only applicable if the unperturbed Hamiltonian is diagonal. Mutually exclusive with ``subspace_eigenvectors``. If neither ``subspace_eigenvectors`` nor ``subspace_indices`` are provided, the BlockSeries is defined with a single block. solver_options : Dictionary containing the options to pass to the Sylvester solver. In the KPM path, see `~pymablock.block_diagonalization.solve_sylvester_KPM` for details. In the direct path, ``eigenvalue_atol`` sets the tolerance used to group explicit eigenvalues into degenerate energy subspaces before constructing the constrained sparse solves. direct_solver : Whether to use the direct sparse solver (default). It prefers MUMPS when available and otherwise falls back to SciPy's sparse LU. Otherwise, the KPM solver is used. Only applicable if the implicit method is used (i.e. `subspace_eigenvectors` is incomplete). Non-Hermitian implicit mode currently requires the direct solver unless a custom ``solve_sylvester`` is provided. symbols : Symbols that label the perturbative parameters of a symbolic Hamiltonian. The order of the symbols is mapped to the indices of the Hamiltonian, see `~pymablock.series.BlockSeries`. If None, the perturbative parameters are taken from the input Hamiltonian. atol : Absolute tolerance to consider matrices as exact zeros. This is used to validate that the unperturbed Hamiltonian is block-diagonal. fully_diagonalize : Indices of the blocks that should be fully diagonalized. If the Hamiltonian only has one block, it is fully diagonalized by default. Alternatively can be a dictionary with the indices of the diagonal blocks as keys and as values appropriately shaped numpy boolean arrays marking which matrix elements should be eliminated by the diagonalization. If there is only one block, the boolean array may be provided directly without a dictionary. For Hermitian problems this mask must be symmetric, and may not have any True values corresponding to matrix elements coupling degenerate eigenvalues. If the Hamiltonian has second-quantized operators, the array may be a sympy matrix instead, and its entries must correspond to sums of all possible operator powers to eliminate, e.g. ``a + Dagger(a)`` or ``a ** k + Dagger(a) ** k``. hermitian : Whether to use the Hermitian algorithm (default). If set to False, use the non-Hermitian similarity-transform algorithm. The non-Hermitian left/right pair form of ``subspace_eigenvectors`` requires ``hermitian=False``. Returns ------- H_tilde : `~pymablock.series.BlockSeries` Block diagonalized Hamiltonian. U : `~pymablock.series.BlockSeries` Transformation that block diagonalizes H. U_inv : `~pymablock.series.BlockSeries` Inverse of U. For Hermitian problems this coincides with the adjoint. """ if isinstance(symbols, sympy.Symbol): symbols = [symbols] algorithm = main if hermitian else nonhermitian if solve_sylvester is not None and fully_diagonalize: raise NotImplementedError( "Full diagonalization is not yet supported with custom Sylvester solvers." ) # We may not use the full operator_to_BlockSeries here because we need # access to the unperturbed Hamiltonian in the implicit mode. # # Because both functions below are idempotent, this does not lead to inconsistencies # with operator_to_BlockSeries in the code below. hamiltonian = _unpack_blocks( _to_scalar_BlockSeries( hamiltonian, symbols, atol, check_hermitian=hermitian, ), atol, ) solver_options = {} if solver_options is None else dict(solver_options) use_implicit = False right_subspaces = left_subspaces = None if subspace_eigenvectors is not None: subspace_eigenvectors = tuple(subspace_eigenvectors) if hermitian and any( isinstance(subspace, tuple) for subspace in subspace_eigenvectors ): raise ValueError( "Hermitian problems do not accept `(right, left)` subspace pairs. " "Pass a single basis per subspace instead." ) right_subspaces, left_subspaces = _normalize_subspace_eigenvectors( subspace_eigenvectors ) _check_biorthonormality(right_subspaces, left_subspaces, atol=atol) num_vectors = sum(vecs.shape[1] for vecs in right_subspaces) dim = right_subspaces[0].shape[0] use_implicit = num_vectors < dim if use_implicit: if hamiltonian.shape: raise ValueError("Implicit mode requires an input not separated into blocks") h_0 = hamiltonian[(0,) * hamiltonian.n_infinite] if isinstance(h_0, sympy.MatrixBase): raise ValueError("Implicit mode is not supported with symbolic Hamiltonian.") assert subspace_eigenvectors is not None # for mypy assert right_subspaces is not None and left_subspaces is not None # for mypy if not hermitian and solve_sylvester is None and not direct_solver: raise NotImplementedError( "Non-Hermitian implicit mode does not support the KPM solver. " "Use direct_solver=True or provide solve_sylvester." ) # Build solve_sylvester if h_0.shape[0] != right_subspaces[0].shape[0]: raise ValueError("`subspace_eigenvectors` does not match the shape of `h_0`.") if solve_sylvester is None: if not all( isinstance(vecs, np.ndarray) for vecs in (*right_subspaces, *left_subspaces) ): raise TypeError( "Implicit problem requires numpy arrays for subspace vectors." ) if direct_solver: direct_solver_options = { key: value for key, value in solver_options.items() if key in {"eigenvalue_atol", "atol", "eps"} } if ( "eigenvalue_atol" not in direct_solver_options and "atol" not in direct_solver_options ): direct_solver_options["eigenvalue_atol"] = atol solve_sylvester = solve_sylvester_direct( h_0, list(subspace_eigenvectors), nonhermitian=not hermitian, **direct_solver_options, ) else: if any(isinstance(subspace, tuple) for subspace in subspace_eigenvectors): raise NotImplementedError( "The implicit KPM solver does not support distinct left and right " "subspace vectors." ) solve_sylvester = solve_sylvester_KPM( h_0, right_subspaces, solver_options=solver_options, ) # Normalize the Hamiltonian H = operator_to_BlockSeries( hamiltonian, name="H", subspace_eigenvectors=subspace_eigenvectors, subspace_indices=subspace_indices, implicit=use_implicit, symbols=symbols, atol=atol, hermitian=hermitian, ) if H.shape[0] == 1: if isinstance(fully_diagonalize, (np.ndarray, sympy.MatrixBase, sympy.Expr)): fully_diagonalize = {0: fully_diagonalize} elif not len(fully_diagonalize): fully_diagonalize = (0,) else: if isinstance(fully_diagonalize, (np.ndarray, sympy.MatrixBase, sympy.Expr)): raise ValueError( "If the Hamiltonian has multiple blocks, `fully_diagonalize` may not be an ndarray." ) # Convert scalar expressions in fully_diagonalize to sympy matrices. For that it is # sufficient to test for sympy.Expr because sympy.MatrixBase is not a subclass of # sympy.Expr. if isinstance(fully_diagonalize, dict): fully_diagonalize = { key: (sympy.Matrix([[value]]) if isinstance(value, sympy.Expr) else value) for key, value in fully_diagonalize.items() } zero_order = (0,) * H.n_infinite for i in range(H.shape[0]): for j in range(H.shape[1]): if i == j or (hermitian and i > j): continue block = H[(i, j, *zero_order)] if block is not zero: if isinstance(block, (sympy.MatrixBase, sympy.Expr)): # This may happen if the expression wasn't simplified enough. warn( "Cannot confirm that the unperturbed Hamiltonian is " "block-diagonal. The algorithm will assume that " f"block ({i}, {j}) is zero.", UserWarning, ) continue raise ValueError( "The off-diagonal elements of the unperturbed Hamiltonian must be zero." ) # Determine operator to use for multiplication. We prefer matmul, and use mul if # matmul is not available. H_0_diag = [H[(i, i, *zero_order)] for i in range(H.shape[0])] nonzero_blocks = [block for block in H_0_diag if block is not zero] if not nonzero_blocks: raise ValueError("The diagonal of the unperturbed Hamiltonian may not be zero.") # Check if H_0_diag contains scalar expressions (not matrices). Once again we use # that sympy.MatrixBase is not a subclass of sympy.Expr. scalar_input = any(isinstance(block, sympy.Expr) for block in nonzero_blocks) if all(hasattr(H, "__matmul__") for H in nonzero_blocks): operator = matmul elif all(hasattr(H, "__mul__") for H in nonzero_blocks): operator = mul else: raise ValueError("The unperturbed Hamiltonian is not a valid operator.") # Extract the default operators from the Hamiltonian. If operators aren't empty, # we're dealing with a second-quantized problem. if any(isinstance(block, (sympy.MatrixBase, sympy.Expr)) for block in nonzero_blocks): operators = list( set().union(*(find_operators(block) for block in nonzero_blocks)) ) operators = tuple( sorted( operators, key=lambda op: (generator_types.index(type(op)), str(op.name)) ) ) else: operators = () # To handle the second-quantized problem, convert the Hamiltonian to our # NumberOrderedForm. if operators: H_orig = H def H_eval(*index): result = H_orig[index] if result is zero: return zero if scalar_input and not isinstance(result, sympy.MatrixBase): result = sympy.Matrix([[result]]) if isinstance(result, sympy.Matrix): return result.applyfunc( lambda x: NumberOrderedForm.from_expr(x, operators) ) H = BlockSeries( eval=H_eval, shape=H_orig.shape, n_infinite=H_orig.n_infinite, dimension_names=H_orig.dimension_names, name=H_orig.name, ) # If solve_sylvester is not yet defined, use the diagonal one. if solve_sylvester is None or use_implicit: diagonal = _extract_diagonal(H, atol, use_implicit, operators) if solve_sylvester is None: if not operators: solve_sylvester = solve_sylvester_diagonal(diagonal, atol=atol) else: solve_sylvester = second_quantization.solve_sylvester_2nd_quant(diagonal) # When the input Hamiltonian value is a linear operator, so should be the output. use_linear_operator = np.zeros(H.shape, dtype=bool) if isinstance(H[(-1, -1, *zero_order)], sparse.linalg.LinearOperator): use_linear_operator[-1, -1] = True if H.shape[0] - 1 in fully_diagonalize: raise ValueError("Fully diagonalizing an implicit block is not supported.") if operator is not matmul: raise ValueError("Implicit mode requires matmul operator.") # Catch the solve_sylvester that uses the old signature without index. if len(signature(solve_sylvester).parameters) == 1: if not hermitian: raise NotImplementedError( "Non-Hermitian problems require `solve_sylvester(Y, index)`. " "Legacy one-argument Sylvester solvers are not supported." ) warn( "One-argument `solve_sylvester(Y)` is deprecated; use " "`solve_sylvester(Y, index)` instead. It will be removed in " "version 2.4.0.", DeprecationWarning, stacklevel=2, ) solve_sylvester = _preprocess_sylvester(solve_sylvester) if not isinstance(fully_diagonalize, dict): commuting_blocks = [True] * H.shape[0] else: commuting_blocks = [i not in fully_diagonalize for i in range(H.shape[0])] scope = { "solve_sylvester": solve_sylvester, "use_linear_operator": use_linear_operator, "two_block_optimized": H.shape[0] == 2 and not fully_diagonalize, "commuting_blocks": commuting_blocks, } equal_eigs = { i: ( (np.abs(diagonal[i].reshape(-1, 1) - diagonal[i]) < atol).astype(int) if diagonal[i].dtype != object # numerical array, else sympy else ((diagonal[i].reshape(-1, 1) == diagonal[i]) == True) # noqa E712 ) for i in set(fully_diagonalize) } if not fully_diagonalize: pass elif not operators: # Determine degenerate eigensubspaces of the blocks to fully diagonalize. if isinstance(fully_diagonalize, dict): # Check that `fully_diagonalize` is symmetric. for to_eliminate in fully_diagonalize.values(): # Check that `fully_diagonalize` has the right format. In particular we # do not allow `to_eliminate` to have the format made for second # quantized Hamiltonians (type Mask) if H_0 has no operators. if not isinstance(to_eliminate, np.ndarray): raise ValueError( "The values of fully_diagonalize dictionary must be numpy arrays." ) if hermitian and not (to_eliminate == to_eliminate.T).all(): raise ValueError( "The values of fully_diagonalize dictionary must be symmetric." ) # Check that `fully_diagonalize` does not have any True values corresponding # to equal eigenvalues. for i, to_eliminate in fully_diagonalize.items(): if (to_eliminate & equal_eigs[i]).any(): raise ValueError( "Full diagonalization must not eliminate matrix elements corresponding" " to equal eigenvalues." ) to_eliminate = fully_diagonalize to_keep = {i: 1 - eliminate for i, eliminate in to_eliminate.items()} else: to_keep = equal_eigs to_eliminate = {i: 1 - keep for i, keep in to_keep.items()} # Convert numpy arrays to sympy matrices if blocks are symbolic. to_eliminate = { i: ( sympy.Matrix(sympy.S.One * eliminate) if diagonal[i].dtype == object else eliminate ) for i, eliminate in to_eliminate.items() } to_keep = { i: sympy.Matrix(sympy.S.One * keep) if diagonal[i].dtype == object else keep for i, keep in to_keep.items() } def diag(x, index): x = x[index] if isinstance(x, BlockSeries) else x if index[0] not in to_keep: return x if isinstance(x, sympy.MatrixBase): return x.multiply_elementwise(to_keep[index[0]]) if sparse.issparse(x): return x.multiply(to_keep[index[0]]) return x * to_keep[index[0]] def offdiag(x, index): if index[0] not in to_keep: return zero x = x[index] if isinstance(x, BlockSeries) else x if isinstance(x, sympy.MatrixBase): return x.multiply_elementwise(to_eliminate[index[0]]) if sparse.issparse(x): return x.multiply(to_eliminate[index[0]]) return x * to_eliminate[index[0]] scope["diag"] = diag scope["offdiag"] = offdiag else: if isinstance(fully_diagonalize, dict): # fully_diagonalize is a dictionary with the indices of the diagonal blocks # as keys and and values sympy matrices with second quantized expressions # showing which matrix elements should be eliminated by the diagonalization. # We convert it to a numpy array so that the elements are mutable # (we are going to store the operators we encounter). fully_diagonalize = { key: np.array(sympy.sympify(value).applyfunc(NumberOrderedForm.from_expr)) for key, value in fully_diagonalize.items() } def diag(x, index): x = x[index] if isinstance(x, BlockSeries) else x if index[0] not in fully_diagonalize or x is zero: return x return second_quantization.apply_mask_to_operator( x, fully_diagonalize[index[0]], keep=False ) def offdiag(x, index): if index[0] not in fully_diagonalize: return zero x = x[index] if isinstance(x, BlockSeries) else x if x is zero: return zero return second_quantization.apply_mask_to_operator( x, fully_diagonalize[index[0]], keep=True ) else: # fully_diagonalize is a list of block indices to fully diagonalize. # Like above, we convert it to a numpy array. keep = { block_idx: np.array( sympy.Matrix(equal_eigs[block_idx].astype(int)).applyfunc( NumberOrderedForm.from_expr ) ) for block_idx in fully_diagonalize } def diag(x, index): x = x[index] if isinstance(x, BlockSeries) else x if index[0] not in keep or x is zero: return x return second_quantization.apply_mask_to_operator( x, keep[index[0]], keep=True ) def offdiag(x, index): if index[0] not in keep: return zero x = x[index] if isinstance(x, BlockSeries) else x if x is zero: return zero return second_quantization.apply_mask_to_operator( x, keep[index[0]], keep=False ) scope["diag"] = diag scope["offdiag"] = offdiag outputs, _ = series_computation( {"H": H}, algorithm=algorithm, scope=scope, operator=operator, ) # Simplify the results and unwrap them for scalar inputs - convert 1x1 matrices back # to scalars if operators: def create_postprocessing_eval(block_series): """Create an eval function that unwraps 1x1 matrices to scalars.""" def postprocessing_eval(*index): result = block_series[index] if not isinstance(result, sympy.MatrixBase): return result result = result.applyfunc( lambda x: x._poly_simplify() if isinstance(x, NumberOrderedForm) else x ) if ( scalar_input and isinstance(result, sympy.MatrixBase) and result.shape == (1, 1) ): return result[0, 0] return result return BlockSeries( eval=postprocessing_eval, shape=block_series.shape, n_infinite=block_series.n_infinite, dimension_names=block_series.dimension_names, name=block_series.name, ) return tuple( create_postprocessing_eval(outputs[name]) for name in ["H_tilde", "U", "U†"] ) return outputs["H_tilde"], outputs["U"], outputs["U†"]
### Converting different formats to BlockSeries
[docs] def operator_to_BlockSeries( operator: list | dict | BlockSeries | sympy.Matrix, *, name: str | None = None, subspace_eigenvectors: tuple[Any, Any] | None = None, subspace_indices: tuple[int, ...] | None = None, implicit: bool = False, symbols: list[sympy.Symbol] | None = None, atol: float = 1e-12, hermitian: bool = False, ) -> BlockSeries: """Convert an operator to `~pymablock.series.BlockSeries` format. The normalization consists of the following steps: - Determine perturbation parameters from the input format, - Separate the operator into blocks based on eigenvectors or subspace indices, - Form a `~pymablock.series.BlockSeries`. Parameters ---------- operator : Full symbolic or numeric operator. It is normalized to a `~pymablock.series.BlockSeries` and separated into blocks corresponding to different subspaces. Supported formats: - A list, of the form ``[h_0, h_1, h_2, ...]`` where ``h_0`` is the unperturbed operator and ``h_1, h_2, ...`` are the first order perturbations. The elements ``h_i`` may be `~sympy.matrices.dense.Matrix`, `~numpy.ndarray`, `~scipy.sparse.spmatrix`, that require separating into blocks. Otherwise, ``h_i`` may be a list of lists with the operator blocks. - A dictionary, of the form ``{(0, 0): h_0, (1, 0): h_1, (0, 1): h_2}``, or ``{1: h_0, x: h_1, y: h_2}`` for symbolic operators. In the former case, the keys must be tuples of integers indicating the order of each perturbation. In the latter case, the keys must be monomials and the indices are ordered as in `operator.dimension_names`. ``h_i`` have the same format as in the list case. - A `~sympy.matrices.dense.Matrix`, unless a list of ``symbols`` is provided as perturbative parameters, all symbols will be treated as perturbative. The normalization to `~pymablock.series.BlockSeries` is done by Taylor expanding in ``symbols`` to the desired order. - A `~pymablock.series.BlockSeries`, used directly. name: Name of the operator. subspace_eigenvectors : A tuple describing the subspaces onto which the operator is projected and separated into blocks. Each entry may be either a single basis ``V`` or, for non-Hermitian problems, a pair ``(R, L)`` of right and left basis vectors, with the single-basis form understood as ``(V, V)``. The non-Hermitian pair format ``(R, L)`` is only supported when ``hermitian=False``. If some vectors are missing, the last block is defined implicitly. Mutually exclusive with ``subspace_indices``. If neither ``subspace_eigenvectors`` nor ``subspace_indices`` are provided, the BlockSeries is defined with a single block. subspace_indices : An array indicating which state belongs to which subspace. If there are two blocks, the labels are 0 for the A (effective) subspace and 1 for the B (auxiliary) subspace. Mutually exclusive with ``subspace_eigenvectors``. If neither ``subspace_eigenvectors`` nor ``subspace_indices`` are provided, the BlockSeries is defined with a single block. implicit : Whether to wrap the operator of the last subspace into a linear operator. symbols : Symbols that label the perturbative parameters of a symbolic operator. The order of the symbols is mapped to the indices of the operator, see `~pymablock.series.BlockSeries`. If None, the perturbative parameters are taken from the unperturbed operator. atol : Absolute tolerance to consider matrices as exact zeros. This is used to validate that the unperturbed operator is block-diagonal. hermitian : Whether the operator may be assumed to be Hermitian (for performance). The non-Hermitian left/right pair form of ``subspace_eigenvectors`` requires ``hermitian=False``. Returns ------- operator : `~pymablock.series.BlockSeries` Transformed operator. """ if subspace_eigenvectors is not None and subspace_indices is not None: raise ValueError( "subspace_eigenvectors and subspace_indices are mutually exclusive." ) to_split = subspace_eigenvectors is not None or subspace_indices is not None operator = _unpack_blocks( _to_scalar_BlockSeries( operator, symbols, atol, check_hermitian=hermitian, ), atol, ) operator.name = name or operator.name if operator.shape: if to_split: raise ValueError( "operator is already separated into blocks but " "subspace_eigenvectors are provided." ) if operator.shape[0] != operator.shape[1]: raise ValueError("operator must be a square block series.") return operator # Separation into subspace_eigenvectors if not to_split: zeroth_order = operator[(0,) * operator.n_infinite] if sparse.issparse(zeroth_order) or isinstance( zeroth_order, (np.ndarray, sympy.MatrixBase) ): subspace_indices = np.zeros(zeroth_order.shape[0], dtype=int) elif isinstance(zeroth_order, sympy.Expr): # Symbolic operator, we assume it is a single block. return BlockSeries( eval=lambda *index: operator[index[2:]], shape=(1, 1), n_infinite=operator.n_infinite, dimension_names=symbols, name=name or operator.name, ) # Define subspace_eigenvectors if subspace_indices is not None: h_0 = operator[(0,) * operator.n_infinite] symbolic = isinstance(h_0, sympy.MatrixBase) subspace_eigenvectors = _subspaces_from_indices( subspace_indices, symbolic=symbolic ) if subspace_eigenvectors is not None: subspace_eigenvectors = tuple(subspace_eigenvectors) if hermitian and any( isinstance(subspace, tuple) for subspace in subspace_eigenvectors ): raise ValueError( "Hermitian problems do not accept `(right, left)` subspace pairs. " "Pass a single basis per subspace instead." ) right_subspaces, left_subspaces = _normalize_subspace_eigenvectors( subspace_eigenvectors ) else: right_subspaces = left_subspaces = None if implicit: assert right_subspaces is not None and left_subspaces is not None # for mypy implicit_projector = ComplementProjector( np.hstack(right_subspaces), np.hstack(left_subspaces), ) right_projectors = (*right_subspaces, implicit_projector) left_projectors = ( *(Dagger(left) for left in left_subspaces), implicit_projector, ) else: right_projectors = right_subspaces left_projectors = ( tuple(Dagger(left) for left in left_subspaces) if left_subspaces is not None else None ) # Separation into subspace_eigenvectors n_blocks = len(right_projectors) def op_eval(*index): left, right = index[:2] if left > right and hermitian: return Dagger(op[(right, left, *tuple(index[2:]))]) original = operator[index[2:]] if original is zero: return zero if implicit and left == right == n_blocks - 1: original = aslinearoperator(original) if right_projectors is None or left_projectors is None: # No subspace_eigenvectors provided, return the original operator return original return _convert_if_zero( left_projectors[left] @ original @ right_projectors[right], atol=atol, ) # We must add op to the local scope because op_eval refers to it. op = BlockSeries( eval=op_eval, shape=(n_blocks, n_blocks), n_infinite=operator.n_infinite, dimension_names=symbols, name=name or operator.name, ) return op
### Different formats and algorithms of solving Sylvester equation. def _preprocess_sylvester(solve_sylvester: Callable) -> Callable: """Wrap the solve_sylvester_callable to handle index and zero values.""" def wrapped(Y: Any, index: tuple[int, ...]) -> Any: if index[:2] not in {(0, 1), (1, 0)}: raise ValueError( "Legacy one-argument Sylvester solvers are only defined for " "two-block off-diagonal terms." ) if isinstance(Y, BlockSeries): Y = Y[index] return solve_sylvester(Y) if Y is not zero else zero return wrapped
[docs] def solve_sylvester_diagonal( eigs: tuple[np.ndarray | sympy.matrices.MatrixBase, ...], vecs_implicit: np.ndarray | None = None, atol: float = 1e-12, ) -> Callable: """Define a function for solving a Sylvester's equation for diagonal matrices. Optionally, this function also applies the eigenvectors of the second matrix to the solution. Parameters ---------- eigs : List of arrays of eigenvalues in each subspace. If `vecs_implicit` are provided, the last subspace corresponds to the partial set of eigenvalues of the implicit subspace. vecs_implicit : Partial eigenvectors of the implicit subspace. Only used for KPM. If provided, the last block is returned in the basis of the original Hilbert space rather than eigenbasis of the Hamiltonian. atol : Absolute tolerance to consider energy differences as exact zeros. Returns ------- solve_sylvester : `Callable` Function that solves Sylvester's equation. """ index_checked = set() def solve_sylvester( Y: np.ndarray | sparse.csr_array | sympy.MatrixBase, index: tuple[int, ...], ) -> np.ndarray | sparse.csr_array | sympy.MatrixBase: if Y is zero: return zero eigs_A, eigs_B = eigs[index[0]], eigs[index[1]] if index[0] != index[1] and index[:2] not in index_checked: compare = np.equal if isinstance(Y, sympy.MatrixBase) else np.isclose if np.any(compare(eigs_A.reshape(-1, 1), eigs_B.reshape(1, -1))): raise ValueError("The subspaces must not share eigenvalues.") index_checked.add(index[:2]) if vecs_implicit is not None and index[1] == len(eigs) - 1: # Needed when the implicit block is returned in the original basis. energy_denominators = 1 / (eigs_A.reshape(-1, 1) - eigs_B) return ((Y @ vecs_implicit) * energy_denominators) @ Dagger(vecs_implicit) if vecs_implicit is not None and index[0] == len(eigs) - 1: # Symmetric companion of the right-implicit case above. energy_denominators = 1 / (eigs_A.reshape(-1, 1) - eigs_B) return vecs_implicit @ ((Dagger(vecs_implicit) @ Y) * energy_denominators) if isinstance(Y, np.ndarray): energy_differences = eigs_A.reshape(-1, 1) - eigs_B with np.errstate(divide="ignore", invalid="ignore"): energy_denominators = np.where( np.abs(energy_differences) > atol, 1 / energy_differences, 0 ) return Y * energy_denominators if sparse.issparse(Y): Y_coo = Y.tocoo() # Sometimes eigs_A/eigs_B can be a scalar zero. eigs_A_select = eigs_A if not eigs_A.shape else eigs_A[Y_coo.row] eigs_B_select = eigs_B if not eigs_B.shape else eigs_B[Y_coo.col] energy_denominators = 1 / (eigs_A_select - eigs_B_select) new_data = Y_coo.data * energy_denominators return sparse.csr_array((new_data, (Y_coo.row, Y_coo.col)), Y_coo.shape) if isinstance(Y, sympy.MatrixBase): array_eigs_a = np.array(eigs_A, dtype=object) # Use numpy to reshape array_eigs_b = np.array(eigs_B, dtype=object) energy_denominators = sympy.Matrix( np.resize(1 / (array_eigs_a.reshape(-1, 1) - array_eigs_b), Y.shape) ).subs(sympy.zoo, sympy.S.Zero) # Take care of diagonal elements return energy_denominators.multiply_elementwise(Y) raise TypeError(f"Unsupported rhs type: {type(Y)}") return solve_sylvester
[docs] def solve_sylvester_KPM( h_0: np.ndarray | sparse.spmatrix, subspace_eigenvectors: tuple[np.ndarray, ...], solver_options: dict | None = None, ) -> Callable: """Solve Sylvester energy division for the Kernel Polynomial Method (KPM). General energy division for numerical problems through either full knowledge of the B-space or application of the KPM Green's function. Parameters ---------- h_0 : Unperturbed Hamiltonian of the system. subspace_eigenvectors : Subspaces to project the unperturbed Hamiltonian and separate it into blocks. solver_options : Dictionary containing any of the following options for KPM. - eps: float Tolerance for Hamiltonian rescaling. - atol: float Accepted precision of the Green's function result in 2-norm. - max_moments: int Maximum number of expansion moments of the Green's function. - auxiliary_vectors: np.ndarray Partial set of eigenvectors of the auxiliary subspace, used to speed up convergence of the KPM solver. Returns ------- solve_sylvester: `Callable` Function that applies divide by energies to the right hand side of Sylvester's equation. """ if solver_options is None: solver_options = {} aux_vectors = solver_options.get("auxiliary_vectors", np.zeros((h_0.shape[0], 0))) subspace_eigenvectors = (*subspace_eigenvectors, aux_vectors) eigs = [ (Dagger(eigenvectors) @ h_0 @ eigenvectors).diagonal() for eigenvectors in subspace_eigenvectors ] kpm_projector = ComplementProjector(np.hstack(subspace_eigenvectors)) # Prepare the Hamiltonian for KPM by rescaling to [-1, 1] bounds_eigs = [np.min(np.concatenate(eigs[:-1])), np.max(np.concatenate(eigs[:-1]))] h_rescaled, (a, b) = rescale( h_0, eps=solver_options.get("eps", 0.01), lower_bounds=bounds_eigs ) eigs_rescaled = [(eig - b) / a for eig in eigs[:-1]] h_rescaled_T = h_rescaled.T # CSR format has a faster matrix-vector product if sparse.issparse(h_rescaled_T): h_rescaled_T = h_rescaled_T.tocsr() def solve_sylvester_kpm(Y: np.ndarray, index: tuple[int]) -> np.ndarray: Y_KPM = Y @ kpm_projector / a # Keep track of Hamiltonian rescaling return np.vstack( [ greens_function( h_rescaled_T, energy, vector, solver_options.get("atol", 1e-5), solver_options.get("max_moments", 1e6), ) for energy, vector in zip(eigs_rescaled[index[0]], Y_KPM) ] ) vecs_implicit = subspace_eigenvectors[-1] solve_sylvester_explicit = solve_sylvester_diagonal( eigs, vecs_implicit, atol=solver_options.get("atol") ) def solve_sylvester(Y: np.ndarray, index: tuple[int]) -> np.ndarray: if Y is zero: return zero if index[1] == len(eigs) - 1: return solve_sylvester_kpm(Y, index) + solve_sylvester_explicit(Y, index) return solve_sylvester_explicit(Y, index) return solve_sylvester
def _group_close_energies(energies: np.ndarray, atol: float) -> list[np.ndarray]: """Group eigenvalue indices that are equal up to ``atol``.""" if not len(energies): return [] if np.isrealobj(energies): order = np.argsort(energies) split_points = np.nonzero(np.diff(energies[order]) > atol)[0] + 1 return np.split(order, split_points) points = np.column_stack((energies.real, energies.imag)) pairs = np.array(list(KDTree(points).query_pairs(r=atol)), dtype=int) if len(pairs): rows = np.concatenate((pairs[:, 0], pairs[:, 1])) cols = np.concatenate((pairs[:, 1], pairs[:, 0])) graph = sparse.coo_array( (np.ones(len(rows), dtype=bool), (rows, cols)), shape=(len(energies), len(energies)), ) else: graph = sparse.coo_array((len(energies), len(energies)), dtype=bool) n_components, labels = sparse.csgraph.connected_components(graph, directed=False) return [np.flatnonzero(labels == label) for label in range(n_components)]
[docs] def solve_sylvester_direct( h_0: sparse.spmatrix | sparse.sparray, eigenvectors: list[DirectSubspaceBasis], *, nonhermitian: bool = False, **solver_options: dict, ) -> Callable[[np.ndarray], np.ndarray]: """Solve Sylvester equation using a direct sparse solver. This function prefers MUMPS, which is a parallel direct solver for sparse matrices, and falls back to SciPy's sparse LU when MUMPS is unavailable. Parameters ---------- h_0 : Unperturbed Hamiltonian of the system. eigenvectors : Bases of the explicit subspaces of the unperturbed Hamiltonian. Each entry may be either a right basis ``V`` or, for non-Hermitian problems, a pair ``(R, L)`` of right and left basis vectors, with ``V`` understood as ``(V, V)``. The direct solver requires these bases to be given as NumPy arrays. nonhermitian : Whether to also prepare the left-implicit Green's functions needed by the non-Hermitian algorithm. This is more expensive and unnecessary for the Hermitian path. **solver_options : Keyword arguments for the direct solver. ``eigenvalue_atol`` controls how explicit eigenvalues are grouped into degenerate subspaces before constructing the constrained sparse solves. It defaults to ``1e-12``. Returns ------- solve_sylvester : `Callable[[numpy.ndarray], numpy.ndarray]` Function that solves the corresponding Sylvester equation. """ right_eigenvectors, left_eigenvectors = _normalize_subspace_eigenvectors( tuple(eigenvectors) ) projector = ComplementProjector( np.hstack(right_eigenvectors), np.hstack(left_eigenvectors), ) eigenvalues = [ np.diag(Dagger(left) @ h_0 @ right) for right, left in zip(right_eigenvectors, left_eigenvectors, strict=True) ] factorization_options = dict(solver_options) deprecated_atol = factorization_options.pop("atol", None) deprecated_eps = factorization_options.pop("eps", None) eigenvalue_atol = factorization_options.pop("eigenvalue_atol", deprecated_atol) if deprecated_atol is not None: warn( "`atol` in `solve_sylvester_direct` is deprecated; use " "`eigenvalue_atol` instead. It will be removed in version 2.4.0.", DeprecationWarning, stacklevel=2, ) if deprecated_eps is not None: warn( "`eps` is ignored by `solve_sylvester_direct` and will be removed " "in version 2.4.0.", DeprecationWarning, stacklevel=2, ) if eigenvalue_atol is None: eigenvalue_atol = 1e-12 def grouped_greens_functions( operator: sparse.spmatrix, right_kernel_subspaces: tuple[np.ndarray | sympy.MatrixBase, ...], left_kernel_subspaces: tuple[np.ndarray | sympy.MatrixBase, ...], conjugate_kernel: bool, ) -> list[list[Callable[[np.ndarray], np.ndarray] | None]]: greens_functions = [] for subspace_eigenvalues, right_kernel_subspace, left_kernel_subspace in zip( eigenvalues, right_kernel_subspaces, left_kernel_subspaces, strict=True, ): grouped = [None] * len(subspace_eigenvalues) for group in _group_close_energies(subspace_eigenvalues, eigenvalue_atol): kernel_vectors = right_kernel_subspace[:, group] left_kernel_vectors = left_kernel_subspace[:, group] if conjugate_kernel: kernel_vectors = kernel_vectors.conj() left_kernel_vectors = left_kernel_vectors.conj() greens_function = direct_greens_function( operator, subspace_eigenvalues[group[0]], kernel_vectors=kernel_vectors, left_kernel_vectors=left_kernel_vectors, **factorization_options, ) for i in group: grouped[i] = greens_function greens_functions.append(grouped) return greens_functions # The non-Hermitian path needs both right- and left-implicit solves. greens_functions_right = grouped_greens_functions( h_0.T, left_eigenvectors, right_eigenvectors, conjugate_kernel=True, ) greens_functions_left = ( grouped_greens_functions( h_0, right_eigenvectors, left_eigenvectors, conjugate_kernel=False, ) if nonhermitian else None ) explicit_part = solve_sylvester_diagonal(eigenvalues, atol=eigenvalue_atol) def solve_sylvester(Y: np.ndarray, index: tuple[int, ...]) -> np.ndarray: if Y is zero: return zero if index[0] < len(eigenvalues) and index[1] < len(eigenvalues): return explicit_part(Y, index) if index[0] == len(eigenvalues): if greens_functions_left is None: raise NotImplementedError( "Left-implicit direct solves require solve_sylvester_direct(" "..., nonhermitian=True)." ) Y = projector @ Y result = np.column_stack( [-gf(vec) for gf, vec in zip(greens_functions_left[index[1]], Y.T)] ) return projector @ result Y = Y @ projector result = np.vstack( [gf(vec) for gf, vec in zip(greens_functions_right[index[0]], Y)] ) return result @ projector return solve_sylvester
### Auxiliary functions. def _list_to_dict(operator: list[Any]) -> dict[int, Any]: """Convert a list of perturbations to a dictionary. Parameters ---------- operator : Unperturbed Hamiltonian and 1st order perturbations. [h_0, h_1, h_2, ...], where h_0 is the unperturbed Hamiltonian and the remaining elements are the 1st order perturbations. This method is for first order perturbations only. Returns ------- operator : `~pymablock.series.BlockSeries` """ n_infinite = len(operator) - 1 # All the perturbations are 1st order zeroth_order = (0,) * n_infinite operator = { zeroth_order: operator[0], **{ tuple(order): perturbation for order, perturbation in zip(np.eye(n_infinite, dtype=int), operator[1:]) }, } return operator def _dict_to_BlockSeries( operator: dict[tuple[int, ...], Any], symbols: Sequence[sympy.Symbol] | None = None, atol: float = 1e-12, ) -> tuple[BlockSeries, list[sympy.Symbol]]: """Convert a dictionary of perturbations to a BlockSeries. Additionally, convert numpy zeroth order to sparse array if it is diagonal and normalize sparse to csr. Parameters ---------- operator : Unperturbed Hamiltonian and perturbations. The keys can be tuples of integers or symbolic monomials. They indicate the order of the perturbation in its respective value. The values are the perturbations, and can be either a `~numpy.ndarray`, `~scipy.sparse.csr_array` or a list with the blocks of the Hamiltonian. For example, {(0, 0): h_0, (1, 0): h_1, (0, 1): h_2} or {1: h_0, x: h_1, y: h_2}. symbols : tuple of symbols to use for the BlockSeries. atol : absolute tolerance for determining if a matrix is diagonal. Returns ------- operator : `~pymablock.series.BlockSeries` """ operator = copy(operator) key_types = set(isinstance(key, sympy.Basic) for key in operator.keys()) if any(key_types): operator, symbols = _symbolic_keys_to_tuples(operator) n_infinite = len(next(iter(operator.keys()))) zeroth_order = (0,) * n_infinite h_0 = operator[zeroth_order] if isinstance(h_0, np.ndarray): if is_diagonal(h_0, atol): operator[zeroth_order] = sparse.csr_array(operator[zeroth_order]) elif sparse.issparse(h_0): # Normalize sparse matrices for solve_sylvester operator[zeroth_order] = sparse.csr_array(operator[zeroth_order]) return BlockSeries( data=operator, shape=(), n_infinite=n_infinite, dimension_names=symbols, ) def _symbolic_keys_to_tuples( hamiltonian: dict[sympy.Basic, Any], ) -> tuple[dict[tuple[int, ...], Any], list[sympy.Basic]]: """Convert symbolic monomial keys to tuples of integers. The key for the unperturbed Hamiltonian is assumed to be 1, and the remaining keys are assumed to be symbolic monomials. Parameters ---------- hamiltonian : Dictionary with symbolic keys, each a monomial without numerical prefactor. The values can be either a `~numpy.ndarray`, `~scipy.sparse.csr_array`, or a list with the blocks of the Hamiltonian. Returns ------- new_hamiltonian : Dictionary with the same values as ``hamiltonian``, but with keys that indicate the order of the perturbation in a tuple. symbols : List of symbols in the order they appear in the keys of `hamiltonian`. The tuple keys of ``new_hamiltonian`` are ordered according to this list. """ # Collect all symbols from the keys symbols = list(set.union(*[key.free_symbols for key in hamiltonian.keys()])) symbols = tuple(sorted(symbols, key=lambda x: x.name)) if not all(symbol.is_commutative for symbol in symbols): raise ValueError("All symbols must be commutative.") # Convert symbolic keys to orders of the perturbation new_hamiltonian = {} for key, value in hamiltonian.items(): monomial = key.as_powers_dict() if monomial.keys() - set(symbols) - {1}: raise ValueError("The Hamiltonian keys must be monomials of symbols") new_hamiltonian[tuple(monomial[s] for s in symbols)] = value return new_hamiltonian, symbols def _sympy_to_BlockSeries( operator: sympy.MatrixBase, symbols: Sequence[sympy.Symbol] = (), check_hermitian: bool = True, ) -> BlockSeries: """Convert a symbolic Hamiltonian to a BlockSeries. Parameters ---------- operator : Symbolic Hamiltonian. symbols : List of symbols that are the perturbative coefficients. If None, all symbols in the Hamiltonian are assumed to be perturbative coefficients. check_hermitian : Whether to check if the operator is Hermitian. Returns ------- operator : `~pymablock.series.BlockSeries` """ if not symbols: symbols = tuple(list(operator.free_symbols)) # All symbols are perturbative if any(n not in operator.free_symbols for n in symbols): raise ValueError("Not all perturbative parameters are in `hamiltonian`.") operator = operator.expand() n_infinite = len(symbols) # Define derivatives through lower order derivatives to avoid recomputing. # We also divide these by the product of factorials to use in the Taylor expansion. operator_derivatives = BlockSeries( data={(0,) * n_infinite: operator}, shape=(), n_infinite=n_infinite, dimension_names=symbols, ) def derivative_eval(*index): # Select an axis along which to take the derivative. symbol_number, order = next((i, n) for i, n in enumerate(index) if n) previous_index = list(index) previous_index[symbol_number] -= 1 return ( operator_derivatives[tuple(previous_index)].diff(symbols[symbol_number]) / order ) operator_derivatives.eval = derivative_eval def op_eval(*index): expr = operator_derivatives[index].subs({n: 0 for n in symbols}) # - Sympy three-valued logic requires "is False". # - The last check is a workaround for sympy issue #27898. (Matrices # with operators are never Hermitian.) if check_hermitian and expr.is_hermitian is False and not expr.atoms(Operator): raise ValueError("Operator must be Hermitian.") expr = expr * reduce(mul, [n**i for n, i in zip(symbols, index)], 1) return _convert_if_zero(expr) op = BlockSeries( eval=op_eval, shape=(), n_infinite=n_infinite, dimension_names=symbols, ) return op def _to_scalar_BlockSeries( operator: list | dict | BlockSeries | sympy.Matrix, symbols: Sequence[sympy.Symbol] = (), atol: float = 1e-12, check_hermitian: bool = True, ) -> BlockSeries: """Normalize input to BlockSeries without transforming values. One exception is that numerical zeroth order term is converted to sparse if it is diagonal, and sparse is normalized to csr. """ if isinstance(operator, BlockSeries): return operator if isinstance(operator, (sympy.Expr, sympy.MatrixBase)): return _sympy_to_BlockSeries( operator, symbols, check_hermitian=check_hermitian, ) if isinstance(operator, list): operator = _list_to_dict(operator) if isinstance(operator, dict): return _dict_to_BlockSeries(operator, symbols, atol) raise TypeError(f"Unsupported input type of Hamiltonian: {type(operator)}.") def _unpack_blocks(operator: BlockSeries, atol: float = 1e-12) -> BlockSeries: """Check if operator values are list of lists and if so, unpack them into blocks.""" if operator.shape: return operator if not isinstance( (zeroth_order := operator[(0,) * operator.n_infinite]), (tuple, list) ): return operator def op_eval(*index): h = _convert_if_zero(operator[index[2:]], atol=atol) if h is zero: return zero try: return _convert_if_zero(h[index[0]][index[1]], atol=atol) except Exception as e: raise ValueError( "Without `subspace_eigenvectors` or `subspace_indices`" " operator must have an NxN block structure." ) from e op = BlockSeries( eval=op_eval, shape=2 * (len(zeroth_order),), n_infinite=operator.n_infinite, dimension_names=operator.dimension_names, ) return op def _subspaces_from_indices( subspace_indices: tuple[int, ...] | np.ndarray, symbolic: bool = False, ) -> tuple[sparse.csr_array, sparse.csr_array]: """Compute subspace eigenvectors from indices of diagonal elements. Parameters ---------- subspace_indices : Subspaces to which the ``subspace_eigenvectors`` belong. Ranges from 0 to ``n_subspaces-1``. symbolic : True if the Hamiltonian is symbolic, False otherwise. If True, the returned subspaces are dense arrays. Returns ------- subspace_eigenvectors : Subspaces to use for block diagonalization. """ subspace_indices = np.array(subspace_indices) dim = len(subspace_indices) eigvecs = sparse.csr_array(sparse.identity(dim, dtype=int, format="csr")) # Canonical basis vectors for each subspace # TODO: review next statement for readability subspace_eigenvectors = tuple( eigvecs[:, np.compress(subspace_indices == block, np.arange(dim))] for block in range(np.max(subspace_indices) + 1) ) if symbolic: # Convert to dense arrays, otherwise they cannot multiply sympy matrices. return tuple(subspace.toarray() for subspace in subspace_eigenvectors) return subspace_eigenvectors def _normalize_subspace_eigenvectors( subspace_eigenvectors: Eigenvectors, ) -> tuple[tuple[Any, ...], tuple[Any, ...]]: """Convert subspace input to explicit right/left basis tuples.""" right_subspaces = [] left_subspaces = [] for subspace in subspace_eigenvectors: if isinstance(subspace, tuple): if len(subspace) != 2: raise ValueError( "Each entry of `subspace_eigenvectors` must be a basis or a " "`(right, left)` pair." ) right, left = subspace else: right = left = subspace if right.shape[0] != left.shape[0]: raise ValueError( "Left and right subspace vectors must have the same ambient dimension." ) if right.shape[1] != left.shape[1]: raise ValueError( "Left and right subspace vectors must have the same number of vectors." ) right_subspaces.append(right) left_subspaces.append(left) return tuple(right_subspaces), tuple(left_subspaces) def _extract_diagonal( operator: BlockSeries, atol: float = 1e-12, implicit: bool = False, operators: Sequence[sympy.physics.quantum.Operator] = (), ) -> tuple[np.ndarray, ...]: """Extract the diagonal of the zeroth order of the Hamiltonian. operator : Hamiltonian BlockSeries. atol : Absolute tolerance for numerical zero. implicit : Whether the last block is defined as a linear operator. operators : List of second-quantized operators used in the Hamiltonian. """ # If using implicit mode, skip the last block. diag_indices = np.arange(operator.shape[0] - implicit) h_0 = operator[(diag_indices, diag_indices) + (0,) * operator.n_infinite] is_sympy = any(isinstance(block, sympy.MatrixBase) for block in h_0) warning = ( "Cannot confirm that the unperturbed Hamiltonian is diagonal, " "which is required if ``solve_sylvester`` is not provided. " "The algorithm will assume that it is diagonal." ) if not all(is_diagonal(h, atol) for h in h_0): warn(warning, UserWarning) diags = [] for block in h_0: if block is zero or block is np.ma.masked: diags.append(np.array(0)) continue eigs = block.diagonal() if is_sympy: # Check if any of the expressions contains sympy.physics.quantum.Operator if operators: eigs = [NumberOrderedForm.from_expr(eig).simplify() for eig in eigs] eigs = np.array(eigs, dtype=object) diags.append(eigs) return tuple(diags) def _convert_if_zero(value: Any, atol: float = 1e-12): """Convert an exact zero to sentinel value zero. Parameters ---------- value : Value to convert to zero. atol : Absolute tolerance for numerical zero. Returns ------- zero : Zero if value is close enough to zero, otherwise value. """ if isinstance(value, np.ndarray): if np.allclose(value, 0, atol=atol): return zero elif sparse.issparse(value): if value.count_nonzero() == 0: return zero elif isinstance(value, sympy.MatrixBase): if value.is_zero_matrix: return zero elif value == 0: return zero return value def _check_biorthonormality(right_subspaces, left_subspaces, atol=1e-12): r"""Check that the explicit left/right subspaces satisfy L^\dagger R = I.""" if isinstance(right_subspaces[0], (np.ndarray, sparse.spmatrix, sparse.sparray)): all_right = np.hstack( [ subspace.toarray() if sparse.issparse(subspace) else subspace for subspace in right_subspaces ] ) all_left = np.hstack( [ subspace.toarray() if sparse.issparse(subspace) else subspace for subspace in left_subspaces ] ) overlap = Dagger(all_left) @ all_right if not np.allclose(overlap, np.eye(all_right.shape[1]), atol=atol): raise ValueError("Subspace vectors must satisfy L^† R = I.") elif isinstance(right_subspaces[0], sympy.MatrixBase): all_right = sympy.Matrix.hstack(*right_subspaces) all_left = sympy.Matrix.hstack(*left_subspaces) overlap = Dagger(all_left) @ all_right # Use sympy three-valued logic if sympy.Eq(overlap, sympy.eye(all_right.shape[1])) == False: # noqa: E712 raise ValueError("Subspace vectors must satisfy L^† R = I.")