Skip to content

Usage

Installation

Simply use pip:

pip install --upgrade multipoles

Discrete Charge Distribution

As example for a discrete charge distribution we model two point charges with positive and negative unit charge located on the z-axis:

from multipoles import MultipoleExpansion

# Prepare the charge distribution dict for the MultipoleExpansion object:

charge_dist = {
    'discrete': True,     # point charges are discrete charge distributions
    'charges': [
        {'q': 1, 'xyz': (0, 0, 1)},
        {'q': -1, 'xyz': (0, 0, -1)},
    ]
}

l_max = 2   # where to stop the infinite multipole sum; here we expand up to the quadrupole (l=2)

Phi = MultipoleExpansion(charge_dist, l_max)

# We can evaluate the multipole expanded potential at a given point like this:

x, y, z = 30.5, 30.6, 30.7
value = Phi(x, y, z)

# The multipole moments are stored in a dict, where the keys are (l, m) and the values q_lm:
Phi.multipole_moments

Continuous Charge Distribution

As an example for a continuous charge distribution, we smear out the point charges from the previous example:

from multipoles import MultipoleExpansion
import numpy as np

# First we set up our grid, a cube of length 16 centered at the origin:

npoints = 81
edge = 16
x, y, z = [np.linspace(-edge/2., edge/2., npoints)]*3
XYZ = np.meshgrid(x, y, z, indexing='ij')


# We model our smeared out charges as gaussian functions:

def gaussian(XYZ, xyz0, sigma):
    g = np.ones_like(XYZ[0])
    for k in range(3):
        g *= np.exp(-(XYZ[k] - xyz0[k])**2 / sigma**2)
    g *= (sigma**2*np.pi)**-1.5
    return g

sigma = 1.5   # the width of our gaussians

# Initialize the charge density rho, which is a 3D numpy array:
rho = gaussian(XYZ, (0, 0, 1), sigma) - gaussian(XYZ, (0, 0, -1), sigma)


# Prepare the charge distribution dict for the MultipoleExpansion object:

charge_dist = {
    'discrete': False,     # we have a continuous charge distribution here
    'rho': rho,
    'xyz': XYZ
}

# The rest is the same as for the discrete case:

l_max = 2   # where to stop the infinite multipole sum; here we expand up to the quadrupole (l=2)

Phi = MultipoleExpansion(charge_dist, l_max)

x, y, z = 30.5, 30.6, 30.7
value = Phi(x, y, z)

Evaluating the Expansion

Phi(x, y, z) accepts numbers as well as arrays of any shape. Numbers and arrays are broadcast against each other, and the result has the broadcast shape:

import numpy as np

Phi(30.5, 30.6, 30.7)                  # a single point

x = np.linspace(10, 20, 5)
Phi(x, 0, 0)                           # 5 points on the x-axis

X, Y = np.meshgrid(x, x, indexing='ij')
Phi(X, Y, 30)                          # a 5x5 array on the plane z=30

With l_max you can restrict the evaluation to the lower multipoles, e.g. to see how much each order contributes. It must not exceed the l_max the expansion was created with:

Phi(30.5, 30.6, 30.7, l_max=0)         # monopole only
Phi(30.5, 30.6, 30.7, l_max=1)         # monopole + dipole

For continuous charge distributions you can also evaluate directly on the grid of the charge density, using slices, indices or boolean masks. A typical use is computing boundary values for a Poisson solver:

Phi[:, :, 0]                           # the plane z = -8, an 81x81 array

mask = np.ones_like(rho, dtype=bool)   # only the boundary of the grid
mask[1:-1, 1:-1, 1:-1] = False
boundary = np.zeros_like(rho)
boundary[mask] = Phi[mask]

The multipole expansion is only valid outside of the charge distribution, so the grid must be large enough for the charge density to have decayed at its boundary (see below). Inside the charge distribution, evaluating Phi[...] gives meaningless values and an ExpansionValidityWarning.

The Field

Phi.field(x, y, z) returns the field \(\mathbf{E} = -\nabla\Phi\), computed analytically from the multipole moments. It takes the same arguments as Phi(x, y, z), including l_max, and returns the components \((E_x, E_y, E_z)\) stacked along the first axis:

Ex, Ey, Ez = Phi.field(30.5, 30.6, 30.7)

E = Phi.field(x, 0, 0)                 # shape (3, 5) for the 5 points from above

Where the Expansion is Valid

The exterior expansion converges only outside the smallest sphere about the expansion center that contains all charge, the interior expansion only inside the largest sphere that contains no charge. The radius of this sphere is available as

Phi.validity_radius

X, Y, Z = XYZ
valid = Phi.is_valid(X, Y, Z)          # boolean mask of the grid points where the
values = np.full(rho.shape, np.nan)    # expansion converges
values[valid] = Phi[valid]

If you evaluate the potential or the field at points where the expansion does not converge, multipoles emits an ExpansionValidityWarning, telling you how many points are affected. To turn these warnings into errors:

import warnings
from multipoles import ExpansionValidityWarning

warnings.simplefilter("error", ExpansionValidityWarning)

A continuous charge density like a gaussian never vanishes exactly. For continuous distributions, the sphere therefore contains all but a fraction charge_tol (default 1e-6) of the total absolute charge. If you evaluate on the boundary of a grid that is too small for the charge density, you get a warning, and the result is less accurate. Enlarge the grid in this case, or, if you are fine with the accuracy, choose a larger charge_tol:

Phi = MultipoleExpansion(charge_dist, l_max, charge_tol=1e-4)

Interior Expansion

By default, multipoles computes the exterior expansion, which is valid outside of the charge distribution. For the potential inside a region free of charge, surrounded by the charges, use the interior expansion:

charge_dist = {
    'discrete': True,
    'charges': [
        {'q': 1, 'xyz': (4, 0, 0)},
        {'q': 1, 'xyz': (-4, 0, 0)},
        {'q': 1, 'xyz': (0, 4, 0)},
        {'q': 1, 'xyz': (0, -4, 0)},
    ]
}

Phi = MultipoleExpansion(charge_dist, l_max=3, interior=True)   # or exterior=False
Phi(0, 0, 0)   # = 4 * 1/4 = 1

Multipole Moments

The total charge, the dipole moment vector and the traceless quadrupole moment tensor, all about the expansion center, are available as

Phi.total_charge         # q
Phi.dipole_moment        # p_i = sum of q x_i
Phi.quadrupole_moment    # Q_ij = sum of q (3 x_i x_j - r^2 delta_ij)

so that \(\Phi = \frac{q}{r} + \frac{\mathbf{p}\cdot\mathbf{x}}{r^3} + \frac{1}{2}\sum_{ij} \frac{Q_{ij} x_i x_j}{r^5} + \dots\) They don't depend on l_max. Except for the lowest non-vanishing moment, they depend on the expansion center.

The spherical multipole moments \(q_{lm}\) (or \(I_{lm}\) for the interior expansion) are available as a dict with keys (l, m), or as an array that you index with [l, m], where negative m wrap around as usual in Python:

Phi.multipole_moments[(2, -1)]
Phi.multipole_moments_array[2, -1]   # the same

The Expansion Center

By default, the expansion is done about the center of charge (weighted by the absolute values of the charges). You can choose any other point with the center argument. This is useful in particular for the interior expansion, which is valid in a sphere about the center that contains no charge:

charge_dist = {
    'discrete': True,
    'charges': [{'q': 1, 'xyz': (0, 0, 0)}]
}

# The center of charge is (0, 0, 0), right at the charge, so the interior expansion
# about it is impossible. Expanding about (5, 0, 0) is fine and valid for r < 5:
Phi = MultipoleExpansion(charge_dist, l_max=10, interior=True, center=(5, 0, 0))
Phi(5, 1, 0)   # = 1 / sqrt(26)

Units and Gravity

multipoles computes \(\Phi = \sum_i q_i / |\mathbf{r} - \mathbf{r}_i|\) (and the corresponding integral for charge densities), i.e. the potential in Gaussian units, and its field \(\mathbf{E} = -\nabla\Phi\). For other unit systems or for gravity, multiply by the appropriate constant:

Quantity Formula
Electrostatic potential, SI units Phi(x, y, z) / (4 * pi * epsilon_0)
Electric field, SI units Phi.field(x, y, z) / (4 * pi * epsilon_0)
Gravitational potential (masses as q) -G * Phi(x, y, z)
Gravitational acceleration (masses as q) -G * Phi.field(x, y, z)

For example, the gravitational potential and acceleration of the Earth-Moon system far away from both:

import numpy as np
from multipoles import MultipoleExpansion

G = 6.674e-11  # m^3 / (kg s^2)

earth_moon = {
    'discrete': True,
    'charges': [
        {'q': 5.972e24, 'xyz': (0, 0, 0)},        # Earth: mass in kg, position in m
        {'q': 7.342e22, 'xyz': (3.844e8, 0, 0)},  # Moon
    ],
}
M = MultipoleExpansion(earth_moon, l_max=4)   # expanded about the center of mass

point = (1e9, 2e9, 0.5e9)
potential = -G * M(*point)            # in J / kg
acceleration = -G * M.field(*point)   # in m / s^2, pointing towards the center of mass

Since all masses are positive, the center of charge is the center of mass here.