[Python] Python fast multipole implementation using Spherical harmonics

Discussão em 'Python' iniciado por Stack, Julho 29, 2025.

  1. Stack

    Stack Membro Participativo

    I'm trying to implement the Fast Multipole Method following the short course

    http://math.nyu.edu/faculty/greengar/shortcourse_fmm.pdf.

    Assume now that the task at hand is to compute the Coulomb interactions in a large particle system. If a point charge of strength $q$ is located at $P_0=\left(x_0, y_0, z_0\right)$, then the potential and electrostatic field due to this charge at a distinct point $P=(x, y, z)$ are given by

    $$\Phi=\frac{1}{\tilde{r}}$$

    where $\tilde{r}$ denotes the distance between points $P_0$ and $P$.

    Theorem 5.2 (Multipole expansion) Suppose that $k$ charges of strengths $\left{q_i, i=1, \ldots, k\right}$ are located at the points $\left{Q_i=\left(\rho_i, \alpha_i, \beta_i\right), i=\right.$ $1, \ldots, k}$, with $\left|\rho_i\right|<a$. Then for any $P=(r, \theta, \phi) \in \mathbf{R}^3$ with $r>a$, the potential $\phi(P)$ is given by Equation (5-15):

    $$\phi(P)=\sum_{n=0}^{\infty} \sum_{m=-n}^n \frac{M_n^m}{r^{n+1}} \cdot Y_n^m(\theta, \phi)$$

    where

    $$M_n^m=\sum_{i=1}^k q_i \cdot \rho_i^n \cdot Y_n^{-m}\left(\alpha_i, \beta_i\right)$$

    Theorem 5.3 (Translation of a multipole expansion) Suppose that $l$ charges of strengths $q_1, q_2, \cdots, q_l$ are located inside the sphere $D$ of radius a with center at $Q=(\rho, \alpha, \beta)$, and that for points $P=(r, \theta, \phi)$ outside $D$, the potential due to these charges is given by the multipole expansion

    $$\Phi(P)=\sum_{n=0}^{\infty} \sum_{m=-n}^n \frac{O_n^m}{r^{\prime n+1}} \cdot Y_n^m\left(\theta^{\prime}, \phi^{\prime}\right)$$

    where $P-Q=\left(r^{\prime}, \theta^{\prime}, \phi^{\prime}\right)$. Then for any point $P=(r, \theta, \phi)$ outside the sphere $D_1$ of radius $(a+\rho)$,

    $$\Phi(P)=\sum_{j=0}^{\infty} \sum_{k=-j}^j \frac{M_j^k}{r^{j+1}} \cdot Y_j^k(\theta, \phi)$$ and $$M_j^k=\sum_{n=0}^j \sum_{m=-n}^n \frac{O_{j-n}^{k-m} \cdot i^{|k|-|m|-|k-m|} \cdot A_n^m \cdot A_{j-n}^{k-m} \cdot \rho^n \cdot Y_n^{-m}(\alpha, \beta)}{A_j^k}$$

    with $A_n^m$ defined by

    $$A_n^m=\frac{(-1)^n}{\sqrt{(n-m)!(n+m)!}}$$

    and

    $$O_n^m=\sum_{i=1}^N q_i \cdot r_i^n \cdot Y_n^{-m}\left(\theta_i, \phi_i\right)$$

    $O_n^m$ = Old Multipole (multipole expansion already computed). Its center is on the child center.

    $M_n^m$ = New Multipole. Multipole computed using the parent center but using the already computed $O_n^m$ considering the child center.

    I'm using the Sympy package to compute spherical harmonics

    http://balbuceosastropy.blogspot.com/2015/06/spherical-harmonics-in-python.html

    Spherical harmonics definition from sympy


    $$Y_n^m(\theta, \varphi):=\sqrt{\frac{(2 n+1)(n-m)!}{4 \pi(n+m)!}} \exp (i m \varphi) \mathrm{P}_n^m(\cos (\theta))$$

    Spherical harmonics definition from short course


    $$Y_n^m(\theta, \phi)=\sqrt{\frac{(n-|m|)!}{(n+|m|)!}} \cdot P_n^{|m|}(\cos \theta) e^{i m \phi}$$

    Note that in this case there is no $\frac{(2 n+1)}{4 \pi}$ in the root. Because this difference, this factor should appear in the implementaion of equation (5-15). It is in the function eval_potential in the line:

    term = (4 * np.pi / (2 * n + 1)) * (1 / (r_target**(n + 1))) * harmonic_val * multipole_moments[n, m + n]

    My implementation is OK for the Multipole expansion given by Equation (5-15). However, the Multipole translation, given by Equation (5-20) is not working properly. I checked all the code but I can't find the error. Can someone help me? Here is my code:

    from scipy.special import sph_harm, factorial
    import numpy as np

    def A_nm(n, m):
    """Compute A_n^m coefficient as defined in Greengard's paper."""
    return (-1)**n / np.sqrt(factorial(n - abs(m)) * factorial(n + abs(m)))

    def M2M_theorem53(child_multipole, child_center, parent_center, p):
    """
    Perform M2M translation based on Theorem 5.3 (Greengard, 1997).
    Translates multipole expansion from child center to parent center (origin).

    Parameters:
    child_multipole: np.array of shape (p+1, 2p+1), with child moments O_n^m at [n, m+n]
    child_center: Point object
    parent_center: Point object
    p: order of expansion

    Returns:
    parent_multipole: np.array of shape (p+1, 2p+1), with translated M_j^k
    """
    parent_multipole = np.zeros((p + 1, 2 * p + 1), dtype=complex)

    # Vector from parent to child center
    dx = child_center.x - parent_center.x
    dy = child_center.y - parent_center.y
    dz = child_center.z - parent_center.z

    rho = np.sqrt(dx**2 + dy**2 + dz**2)
    if rho < 1e-14:
    return np.copy(child_multipole) # No translation needed

    alpha = np.arctan2(dy, dx)
    beta = np.arccos(dz / rho)

    for j in range(p + 1):
    for k in range(-j, j + 1):
    acc = 0.0j
    A_jk = A_nm(j, k)
    for n in range(j + 1):
    for m in range(-n, n + 1):
    j_minus_n = j - n
    k_minus_m = k - m

    if abs(k_minus_m) > j_minus_n:
    continue # Spherical harmonic is 0 outside its domain

    A_nm_val = A_nm(n, m)
    A_jn_km = A_nm(j_minus_n, k_minus_m)

    # Complex exponential factor: i^{|k| - |m| - |k - m|}
    phase = 1j ** (abs(k) - abs(m) - abs(k - m))

    # Y_n^{-m}(alpha, beta)
    Y = sph_harm(-m, n, alpha, beta)

    term = (child_multipole[j_minus_n, k_minus_m + j_minus_n] *
    phase * A_nm_val * A_jn_km * (rho ** n) * Y / A_jk)
    acc += term
    parent_multipole[j, k + j] = acc

    return parent_multipole

    import numpy as np
    from scipy.special import sph_harm
    # from matplotlib import pyplot, rcParams # Not strictly needed for the core logic
    # %matplotlib inline
    # from mpl_toolkits.mplot3d import Axes3D # Not strictly needed for the core logic

    # --- Re-using your Point and Particle classes ---
    class Point():
    """The class for a point."""
    def __init__(self, coords=[], domain=1.0):
    if coords:
    assert len(coords) == 3, "the size of coords should be 3."
    self.x = coords[0]
    self.y = coords[1]
    self.z = coords[2]
    else:
    self.x = domain * np.random.random()
    self.y = domain * np.random.random()
    self.z = domain * np.random.random()

    def distance(self, other):
    return np.sqrt((self.x-other.x)**2 + (self.y-other.y)**2
    + (self.z-other.z)**2)

    class Particle(Point):
    """The derived class for a particle."""
    def __init__(self, coords=[], domain=1.0, m=1.0):
    Point.__init__(self, coords, domain)
    self.m = m # Mass of the particle (charge for potential)
    self.phi = 0. # Gravitational potential, initialized for direct sum

    # --- Your P2M (Particle-to-Multipole) function (with a small enhancement for mass and r=0 handling) ---
    def P2M(sources, center, p):
    """
    Given sources and cell's information, return multipole moments.
    This computes M_n^m = sum q_i r_i^n * conj(Y_n^m(theta_i, phi_i))
    """
    xc, yc, zc = center.x, center.y, center.z

    # Coordinates of sources relative to the center
    x_rel = np.array([source.x for source in sources]) - xc
    y_rel = np.array([source.y for source in sources]) - yc
    z_rel = np.array([source.z for source in sources]) - zc
    masses = np.array([source.m for source in sources])

    r_rel = np.sqrt(x_rel**2 + y_rel**2 + z_rel**2)
    phi_rel = np.arctan2(y_rel, x_rel)

    # Handle sources exactly at the center (r_rel = 0)
    # np.arccos(0/0) results in NaN, set theta to 0 for points at center
    theta_rel = np.arccos(z_rel / np.where(r_rel == 0, 1e-10, r_rel)) # Avoid division by zero
    theta_rel[r_rel == 0] = 0.0 # Standard convention for (0,0,0) in spherical coords

    multipole_moments = np.zeros((p + 1, 2 * p + 1), complex)

    for i in range(len(sources)): # Loop over each source particle
    for n in range(p + 1):
    for m in range(-n, n + 1):
    # q_i * r_i^n * conj(Y_n^m(theta_i, phi_i))
    multipole_moments[n, m + n] += masses * (r_rel**n) * np.conj(sph_harm(m, n, phi_rel, theta_rel))
    return multipole_moments

    # --- Your eval_potential (Multipole-to-Particle, M2P) function (corrected for multiple targets) ---
    def eval_potential(targets, multipole_moments, center, p):
    """
    Given targets list, multipole moments and expansion center, return
    the array of target's potentials.
    This evaluates Phi(r') = sum (4pi/(2n+1)) * M_n^m / (r')^(n+1) * Y_n^m(theta', phi')
    """
    potential_at_targets = np.zeros(len(targets), dtype=complex)

    for j, target in enumerate(targets):
    # Coordinates of target relative to the expansion center
    dx = target.x - center.x
    dy = target.y - center.y
    dz = target.z - center.z

    r_target = np.sqrt(dx**2 + dy**2 + dz**2)

    # This expansion is valid only if r_target > r_max_sources_in_cell.
    # For a target exactly at the center (r_target=0), the expansion is singular.
    # In a full FMM, P2P (direct) interaction handles targets close to sources/expansion center.
    if r_target < 1e-10: # If target is too close to center, expansion is invalid
    potential_at_targets[j] = np.nan # Indicate invalid result for this case
    continue

    phi_target = np.arctan2(dy, dx)
    theta_target = np.arccos(dz / r_target)

    current_potential_sum = 0.0j # Initialize sum for this specific target
    for n in range(p + 1):
    for m in range(-n, n + 1):
    # Y_n^m(theta', phi')
    harmonic_val = sph_harm(m, n, phi_target, theta_target)

    # Term for current (n, m)
    term = (4 * np.pi / (2 * n + 1)) * (1 / (r_target**(n + 1))) * harmonic_val * multipole_moments[n, m + n]
    current_potential_sum += term
    potential_at_targets[j] = current_potential_sum

    return potential_at_targets.real # Potentials are real

    # --- Your direct_sum for verification ---
    def direct_sum(sources, targets):
    """
    Calculate the gravitational potential (target.phi) at each target
    particle using direct summation method.
    """
    phi_values = np.zeros(len(targets))
    for j, target in enumerate(targets):
    current_phi = 0.0
    for source in sources:
    r = target.distance(source)
    if r < 1e-10: # Handle near-singularities / self-interaction
    current_phi += np.inf # For gravitational potential, it goes to infinity
    else:
    current_phi += source.m / r
    phi_values[j] = current_phi
    return phi_values


    # --- Test Setup for M2M ---
    if __name__ == "__main__":
    n_sources = 10 # number of particles in a child cell
    p = 8 # order of expansion

    # 1. Define child cell sources and center
    child_center_coords = [0.25, 0.25, 0.25]
    child_center = Point(child_center_coords)

    np.random.seed(42) # for reproducibility
    # Sources distributed within a small radius around the child center
    # Ensure they are within a sphere that defines the child cell
    radius_child = 0.1
    child_source_coords = np.random.uniform(-radius_child, radius_child,(n_sources, 3)) + np.array(child_center_coords)
    child_sources = [Particle(coord.tolist(), m=1.0) for coord in child_source_coords]

    # 2. Compute child's multipole moments (P2M)
    child_multipole = P2M(child_sources, child_center, p)
    print(f"Child multipole moments computed for {n_sources} sources.")

    # 3. Define parent cell center
    # Parent center should be "outside" the child cell, making d non-zero

    parent_center_coords = [0.5, 0.5, 0.5] # E.g., a larger cell's center


    parent_center = Point(parent_center_coords)
    print(f"Parent center: {parent_center_coords}")
    print(f"Child center: {child_center_coords}")

    # 4. Perform M2M translation
    parent_multipole_direct = P2M(child_sources, parent_center, p)

    parent_multipole_M2M = M2M_theorem53(child_multipole, child_center, parent_center, p)



    print(f"M2M translation performed.")

    # 5. Define a distant target to test potentials
    # The target must be sufficiently far from *both* child and parent centers
    # for the multipole expansions to be accurate.
    target_coords = [[2.0, 2.0, 2.0]] # Very distant target
    targets = [Particle(coord) for coord in target_coords]
    print(f"Target for evaluation: {target_coords[0]}")

    # 6. Evaluate potential using child_multipole (centered at child_center)
    # The target must be outside the smallest sphere enclosing the sources centered at child_center
    phi_from_child_m = eval_potential(targets, child_multipole, child_center, p)
    print(f"Potential from child multipole: {phi_from_child_m[0]:.12e}")

    # 7. Evaluate potential using parent_multipole (centered at parent_center)
    # The target must be outside the smallest sphere enclosing the sources centered at parent_center
    phi_from_parent_m = eval_potential(targets, parent_multipole_M2M, parent_center, p)
    print(f"Potential from parent multipole: {phi_from_parent_m[0]:.12e}")

    # 8. Direct sum for comparison (ground truth)
    # This involves directly summing contributions from all original child sources to the target
    phi_direct = direct_sum(child_sources, targets)
    print(f"Potential from direct sum: {phi_direct[0]:.12e}")

    # 9. Check agreement
    error_child_vs_direct = np.abs(phi_from_child_m[0] - phi_direct[0])
    error_parent_vs_direct = np.abs(phi_from_parent_m[0] - phi_direct[0])
    error_m2m_consistency = np.abs(phi_from_parent_m[0] - phi_from_child_m[0])

    print(f"\nErrors (absolute difference):")
    print(f" Child multipole vs Direct: {error_child_vs_direct:.2e}")
    print(f" Parent multipole vs Direct: {error_parent_vs_direct:.2e}")
    print(f" M2M consistency (Parent vs Child potential): {error_m2m_consistency:.2e}")

    if error_m2m_consistency < 1e-9: # A small tolerance for floating point comparisons
    print("\nM2M translation appears to be working correctly: "
    "Potential from parent's moments matches child's moments.")
    else:
    print("\nWarning: M2M translation might have an issue. "
    "Check the consistency error.")



    The result I obtained is:

    Child multipole moments computed for 10 sources.
    Parent center: [0.5, 0.5, 0.5]
    Child center: [0.25, 0.25, 0.25]
    M2M translation performed.
    Target for evaluation: [2.0, 2.0, 2.0]
    Potential from child multipole: 3.275435467795e+00
    Potential from parent multipole: 1.123673185858e+00
    Potential from direct sum: 3.275435467795e+00

    Errors (absolute difference):
    Child multipole vs Direct: 8.26e-14
    Parent multipole vs Direct: 2.15e+00
    M2M consistency (Parent vs Child potential): 2.15e+00

    Warning: M2M translation might have an issue. Check the consistency error.

    Continue reading...

Compartilhe esta Página