Source code for pymablock.second_quantization

"""Second quantization tools.

See number_ordered_form_plan.md for the plan to implement NumberOrderedForm as an
Operator subclass for better representation of number-ordered expressions.
"""

from collections.abc import Callable

import numpy as np
import sympy
from sympy.physics.quantum.boson import BosonOp

from pymablock.number_ordered_form import (
    LadderOp,
    NumberOperator,
    NumberOrderedForm,
    _number_operator_to_placeholder,
)
from pymablock.series import zero

__all__ = [
    "apply_mask_to_operator",
    "solve_sylvester_2nd_quant",
]


def solve_scalar(
    Y: sympy.Expr,
    H_ii: sympy.Expr,
    H_jj: sympy.Expr,
    diagonal: bool = False,
) -> NumberOrderedForm:
    """Solve a scalar Sylvester equation with 2nd quantized operators.

    `H_ii` and `H_jj` are scalar expressions containing number operators of
    possibly several bosons, fermions, and spin operators.

    See more details of how this works in the :doc:`second quantization
    documentation <../second_quantization>`.

    Parameters
    ----------
    Y :
        Expression with raising and lowering bosonic, fermionic, and spin operators.
    H_ii :
        Sectors of the unperturbed Hamiltonian.
    H_jj :
        Sectors of the unperturbed Hamiltonian.
    diagonal : bool
        If True, we're evaluating the diagonal entry of the matrix operator,
        which means that `Y` is Hermitian and `H_ii` and `H_jj` are equal. This
        is used to speed up the computation.

    Returns
    -------
    NumberOrderedForm
        Result of the Sylvester equation for 2nd quantized operators.

    Notes
    -----
    See the second quantization documentation for the derivation.

    """
    if Y == 0:
        return sympy.S.Zero

    Y = NumberOrderedForm.from_expr(Y)
    operators = Y.operators

    shifts = Y.terms
    new_shifts = {}
    for shift, coeff in shifts.items():
        # Ensure that the energy denominator always has the same sign to simplify the
        # expression. We do this by multiplying the denominator by -1 if the shift is
        # lexicographically negative.
        sign = -sympy.S.One if tuple(shift) < (0,) * len(shift) else sympy.S.One
        if diagonal and sign is sympy.S.One:
            continue
        # Commute H_ii and H_jj through creation and annihilation operators
        # respectively.
        #
        # Here we use a private function to get access to the placeholders
        shifted_H_jj = H_jj.xreplace(
            {
                _number_operator_to_placeholder(NumberOperator(op)): (
                    _number_operator_to_placeholder(NumberOperator(op)) + delta
                    if isinstance(op, (BosonOp, LadderOp))
                    else sympy.S.One
                )
                for delta, op in zip(shift, operators)
                if delta > 0
            }
        )
        shifted_H_ii = H_ii.xreplace(
            {
                _number_operator_to_placeholder(NumberOperator(op)): (
                    _number_operator_to_placeholder(NumberOperator(op)) - delta
                    if isinstance(op, (BosonOp, LadderOp))
                    else sympy.S.One
                )
                for delta, op in zip(shift, operators)
                if delta < 0
            }
        )
        if sign is sympy.S.One:
            denominator = shifted_H_ii - shifted_H_jj
        else:
            denominator = shifted_H_jj - shifted_H_ii
        # Denominators often simplify because linear powers of bosonic operators cancel.
        denominator = sympy.collect_const(
            next(iter((denominator.terms.values()))).simplify()
        ).doit()  # Not sure why doit is needed here, but it is.
        new_shifts[shift] = sign * (denominator) ** -sympy.S.One * coeff

    result = (
        NumberOrderedForm(
            operators=Y.args[0],
            terms=new_shifts,
        )
        ._cancel_binary_operator_numbers()
        ._linearize_binary_operators()
    )

    if diagonal:
        result -= result.adjoint()

    return result


[docs] def solve_sylvester_2nd_quant( eigs: tuple[tuple[sympy.Expr, ...], ...], ) -> Callable: """Solve a Sylvester equation for 2nd quantized diagonal Hamiltonians. Parameters ---------- eigs : Tuple of lists of expressions representing the diagonal Hamiltonian blocks. Returns ------- Callable A function that takes a matrix of operators and a tuple of indices, and computes the element-wise solution to the Sylvester equation for those diagonal Hamiltonian blocks. """ eigs = tuple( [NumberOrderedForm.from_expr(eig) for eig in eig_block] for eig_block in eigs ) if any(not eig.is_particle_conserving() for eig_block in eigs for eig in eig_block): raise ValueError( "The diagonal Hamiltonian blocks must contain only number-conserving expressions." ) def solve_sylvester( Y: sympy.MatrixBase, index: tuple[int, ...], ) -> sympy.MatrixBase: if Y is zero: return zero eigs_A, eigs_B = eigs[index[0]], eigs[index[1]] # Handle the case when a block is empty if not eigs_A: eigs_A = eigs[index[0]] = [sympy.S.Zero] * Y.shape[0] if not eigs_B: eigs_B = eigs[index[1]] = [sympy.S.Zero] * Y.shape[1] result = sympy.zeros(*Y.shape) for i in range(Y.rows): for j in range(Y.cols): # Only compute upper triangle of diagonal blocks if index[0] != index[1] or i >= j: result[i, j] = solve_scalar( Y[i, j], eigs_A[i], eigs_B[j], diagonal=(i == j and index[0] == index[1]), ) for i in range(Y.rows): for j in range(Y.cols): # Fill the lower triangle with minus conjugate transpose if index[0] == index[1] and i < j: result[i, j] = -result[j, i].adjoint() return result return solve_sylvester
[docs] def apply_mask_to_operator( operator: sympy.MatrixBase, mask: np.ndarray, keep: bool = True, ) -> sympy.Matrix: """Apply a mask to filter specific terms in a matrix operator. This function selectively keeps terms in a symbolic matrix operator based on their powers of creation and annihilation operators. See more details of how this works in the :doc:`second quantization documentation <../second_quantization>`. Parameters ---------- operator : Matrix operator containing symbolic expressions with second quantized operators. mask : A matrix with `~pymablock.number_ordered_form.NumberOrderedForm` elements that define selection criteria. Specifically, the elements of the `operator[i, j]` with powers matching any `mask[i, j].terms` are selected. keep : If True (default), keep the terms that satisfy any of the conditions. If False discard the terms that satisfy any of the conditions. Used for inverting the mask. Returns ------- filtered: `sympy.matrices.dense.MutableDenseMatrix` A new matrix with the same shape as the input, but containing only the selected terms. Examples -------- Let's filter out terms in a Hamiltonian matrix based on their boson number operators: >>> import sympy >>> from sympy.physics.quantum import boson, Dagger >>> from pymablock.number_ordered_form import NumberOrderedForm >>> from pymablock.second_quantization import apply_mask_to_operator >>> >>> # Create bosonic operators >>> a = boson.BosonOp('a') >>> b = boson.BosonOp('b') >>> >>> # Create a matrix with different operator terms >>> H = sympy.Matrix([[a * Dagger(a) + b * Dagger(b), a * Dagger(b)], [b * Dagger(a), a * Dagger(a) - b * Dagger(b)]]) >>> # Convert to NumberOrderedForm for easier handling >>> H_nof = H.applyfunc(NumberOrderedForm.from_expr) >>> >>> # Create a mask that selects only terms with a specific power pattern >>> # Select only terms with exactly one 'a' operator and one 'b' operator >>> mask = sympy.Matrix([[sympy.S.Zero, NumberOrderedForm([a, b], {(1, -1): sympy.S.One})], [NumberOrderedForm([a, b], {(1, 1): sympy.S.One}), sympy.S.Zero]]) >>> H_filtered = apply_mask_to_operator(H_nof, mask, keep=True) >>> # H_filtered now contains only the terms that match the mask: >>> # [[0, Dagger(b)a], [0, 0]] """ result = sympy.zeros(operator.rows, operator.cols) for i in range(operator.rows): for j in range(operator.cols): value = operator[i, j] if not value: continue if not mask[i, j]: if not keep: result[i, j] = value continue value = NumberOrderedForm.from_expr(value) value, mask[i, j] = value._combine_operators(mask[i, j]) assert isinstance(value, NumberOrderedForm) result[i, j] = value.filter_terms(tuple(mask[i, j].terms), keep) return result