Source code for pymablock.linalg

# ruff: noqa: N803, N806
"""Linear algebra utilities."""

import warnings
from collections.abc import Callable
from typing import Any

import numpy as np
import sympy
from scipy import sparse
from scipy.linalg import qr
from scipy.sparse import identity, spmatrix
from scipy.sparse.linalg import LinearOperator, factorized
from scipy.sparse.linalg import aslinearoperator as scipy_aslinearoperator

from pymablock.series import one, zero


def _kernel_pivot_rows(kernel_vectors: np.ndarray) -> np.ndarray:
    """Choose pivot rows that fix a gauge for a degenerate kernel."""
    if kernel_vectors.shape[1] == 0:
        return np.array([], dtype=int)

    _, _, pivots = qr(kernel_vectors.T, mode="economic", pivoting=True)
    return np.sort(pivots[: kernel_vectors.shape[1]])


def _constrain_matrix(
    mat: sparse.sparray | spmatrix,
    pivot_rows: np.ndarray,
) -> sparse.csr_array:
    """Replace selected equations with x[row] = 0 constraints."""
    constrained = sparse.csr_array(mat)
    if pivot_rows.size == 0:
        return constrained

    pivot_mask = np.zeros(constrained.shape[0], dtype=bool)
    pivot_mask[pivot_rows] = True

    # Drop all entries on constrained rows, then add back the diagonal ones
    # that enforce x[row] = 0 on those rows.
    constrained_coo = constrained.tocoo(copy=False)
    keep = ~pivot_mask[constrained_coo.row]
    rows = np.concatenate((constrained_coo.row[keep], pivot_rows))
    cols = np.concatenate((constrained_coo.col[keep], pivot_rows))
    data = np.concatenate(
        (
            constrained_coo.data[keep],
            np.ones(len(pivot_rows), dtype=constrained.dtype),
        )
    )

    return sparse.csr_array((data, (rows, cols)), shape=constrained.shape)


[docs] def direct_greens_function( h: spmatrix, E: float, kernel_vectors: np.ndarray | None = None, left_kernel_vectors: np.ndarray | None = None, atol: float | None = None, eps: float | None = None, ) -> Callable[[np.ndarray], np.ndarray]: """Compute the Green's function of a Hamiltonian using a sparse direct solver. Parameters ---------- h : Hamiltonian matrix. E : Energy at which to compute the Green's function. kernel_vectors : Basis of the right kernel of ``E - H``. When provided, the solver fixes the gauge by replacing pivot equations selected with pivoted QR by ``x[pivot] = 0`` constraints and projects the kernel away before and after the sparse solve. If omitted, an empty kernel basis is used. left_kernel_vectors : Basis dual to ``kernel_vectors`` used to construct the kernel projector. If omitted, ``kernel_vectors`` is used on both sides. atol : Deprecated. Ignored and will be removed in version 2.4.0. eps : Deprecated. Ignored and will be removed in version 2.4.0. Returns ------- greens_function : `Callable[[np.ndarray], np.ndarray]` Function that solves :math:`(E - H) sol = vec`. """ mat = E * sparse.csr_array(identity(h.shape[0], dtype=h.dtype, format="csr")) - h if kernel_vectors is None: kernel_vectors = np.zeros((h.shape[0], 0), dtype=h.dtype) if left_kernel_vectors is None: left_kernel_vectors = kernel_vectors deprecated_arguments = [ name for name, value in (("atol", atol), ("eps", eps)) if value is not None ] if deprecated_arguments: argument_names = ", ".join(f"`{name}`" for name in deprecated_arguments) verb = "are" if len(deprecated_arguments) > 1 else "is" warnings.warn( f"{argument_names} {verb} ignored by `direct_greens_function` and " "will be removed in version 2.4.0.", DeprecationWarning, stacklevel=2, ) pivot_rows = _kernel_pivot_rows(kernel_vectors) kernel_projector = ComplementProjector(kernel_vectors, left_kernel_vectors) mat = _constrain_matrix(mat, pivot_rows) is_complex = np.iscomplexobj(mat.data) try: from mumps import Context as MUMPSContext except ImportError: solve = factorized(sparse.csc_matrix(mat)) else: ctx = MUMPSContext() # MUMPS does not support Hermitian matrices, so we use the symmetric only with real. ctx.set_matrix( sparse.coo_array(mat), overwrite_a=True, symmetric=not is_complex and not pivot_rows.size, ) ctx.factor() def solve(v: np.ndarray) -> np.ndarray: return ctx.solve(v, overwrite_b=True) def greens_function(vec: np.ndarray) -> np.ndarray: """Apply the Green's function to a vector. Parameters ---------- vec : Vector to which to apply the Green's function. If the Green's function is evaluated at an eigenenergy, this vector must be orthogonal to the corresponding eigenvector(s). Returns ------- sol : Solution of :math:`(E - H) sol = vec`. """ vec = kernel_projector @ vec vec[pivot_rows] = 0 if np.iscomplexobj(vec) and not is_complex: vec = (vec.real, vec.imag) else: vec = (vec,) sol = [] for v in vec: sol.append(solve(v)) result = sol[0] if len(sol) == 1 else sol[0] + 1j * sol[1] return kernel_projector @ result return greens_function
class ComplementProjector(LinearOperator): r"""Projector on the complement of the span of a set of vectors. This is used to compute $P_B = I - P_A$ where $P_A$ is the projector on the span of the vectors $A$ in the implicit method. """ def __init__( self: LinearOperator, vecs: np.ndarray, left_vecs: np.ndarray | None = None, ) -> LinearOperator: """Projector on the complement of the span of vecs.""" self.shape = (vecs.shape[0], vecs.shape[0]) self._vecs = vecs self._hermitian = ( left_vecs is None or left_vecs is vecs or np.array_equal(left_vecs, vecs) ) self._left_vecs = vecs if self._hermitian else left_vecs self.dtype = np.result_type(self._vecs.dtype, self._left_vecs.dtype) self._adjoint_operator = self if self._hermitian else None self._conjugate_operator = None self._transpose_operator = None __array_ufunc__ = None def _apply(self: LinearOperator, v: np.ndarray) -> np.ndarray: return v - self._vecs @ (self._left_vecs.conj().T @ v) _matvec = _matmat = _apply def _apply_left(self: LinearOperator, v: np.ndarray) -> np.ndarray: return v - self._left_vecs.conj() @ (self._vecs.T @ v) _rmatvec = _rmatmat = _apply_left def _adjoint(self: LinearOperator) -> LinearOperator: if self._adjoint_operator is None: self._adjoint_operator = self.__class__( vecs=self._left_vecs, left_vecs=self._vecs, ) self._adjoint_operator._adjoint_operator = self return self._adjoint_operator def conjugate(self: LinearOperator) -> LinearOperator: """Conjugate operator.""" if self._conjugate_operator is None: vecs = self._vecs.conj() left_vecs = vecs if self._hermitian else self._left_vecs.conj() self._conjugate_operator = self.__class__( vecs=vecs, left_vecs=left_vecs, ) self._conjugate_operator._conjugate_operator = self if self._hermitian: self._transpose_operator = self._conjugate_operator self._conjugate_operator._transpose_operator = self return self._conjugate_operator def _transpose(self: LinearOperator) -> LinearOperator: if self._transpose_operator is None: self._transpose_operator = ( self.conjugate() if self._hermitian else self.conjugate()._adjoint() ) self._transpose_operator._transpose_operator = self return self._transpose_operator def aslinearoperator(A: Any) -> Any: """Construct a linear operator. Same as `scipy.sparse.linalg.aslinearoperator`, but with passthrough for `~pymablock.series.zero` and `~pymablock.series.one`. """ if A is zero or A is one: return A return scipy_aslinearoperator(A) def is_diagonal(A: Any, atol: float = 1e-12) -> bool: """Check if A is diagonal.""" if A is zero or A is np.ma.masked: return True if isinstance(A, sympy.MatrixBase): return A.is_diagonal() if isinstance(A, np.ndarray): # Create a view of the offdiagonal array elements offdiagonal = A.reshape(-1)[:-1].reshape(len(A) - 1, len(A) + 1)[:, 1:] return not np.any(np.round(offdiagonal, int(-np.log10(atol)))) if sparse.issparse(A): A = sparse.dia_array(A) return not any(A.offsets) raise NotImplementedError(f"Cannot extract diagonal from {type(A)}")