Skip to content

Large clusters

The full T-matrix of a cluster gives the response to every incident mode. For a few incident waves, two solvers compute only the responses you need:

Solver Stores Best for
Cluster.factor(), diff.sphere_cluster_factor the dense coupling matrix and its LU factorization many incident waves on one geometry, while the dense matrix fits in memory
iterative.SphereCluster one 2×2 Mie block per sphere and degree, plus bounded Krylov vectors many spheres and a few incident waves, especially when scattering is weak or dense matrices exceed memory

For 512 spheres with lmax=1 and one incident wave, the dense solve for that wave takes 0.38 s against 0.94 s for the full T-matrix. The matrix-free gradient peaks at 46 MiB of memory, the gradient through the full T-matrix at 1,368 MiB. See Large problems for the historical measurements. The CPU speedup measurements compare the updated solver with 0.1.0: batching several incident waves shares translation work, while each wave keeps its own convergence check. Dense LU can still win for strongly coupled clusters and repeated solves on one geometry.

Matrix-free sphere clusters

iterative.SphereCluster solves clusters of homogeneous, nonmagnetic spheres in vacuum. It takes the incident field as a physical wave or as coefficient columns, and agrees with the dense Cluster:

import numpy as np
import treams_rs as tr
from treams_rs.iterative import SphereCluster

radii, epsilon = [0.2, 0.3], [2.4, 3.1 + 0.02j]
positions = [[0, 0, 0], [1.1, 0.2, 0]]
cluster = SphereCluster(2, 1.3, radii, epsilon, positions)  # lmax, k0, ...
incident = tr.plane_wave(direction=[0, 0, 1], pol="positive_helicity", k0=1.3)
solution = cluster.scatter(incident, rtol=1e-11)
spheres = [
    tr.sphere_tmatrix(k0=1.3, lmax=2, radius=r, material=e)
    for r, e in zip(radii, epsilon)
]
dense = tr.Cluster(spheres, positions=positions).scatter(incident)
np.testing.assert_allclose(solution.wave.coefficients, dense.coefficients, atol=1e-12)

scatter returns a ScatteringSolution(wave, convergence). For coefficient columns, solve returns a Solution(coefficients, convergence), and record returns the solution together with an IterativeContext for gradients.

Coefficient columns and gradients

The incident coefficients are ordered by particle, then by the spherical modes of each particle, with pol 1 (positive helicity) before pol 0. A vector is one incident wave; a matrix of shape (dimension, P) holds P of them.

import numpy as np
from treams_rs.iterative import Gradient, IterativeContext, SphereCluster

cluster = SphereCluster(
    lmax=2,
    k0=1.3,
    radii=[0.2, 0.3],
    epsilon=[2.4, 3.1 + 0.02j],
    positions=[[0, 0, 0], [1.1, 0.2, 0]],
)
incident = np.zeros(cluster.dimension, dtype=complex)
incident[0] = 1  # the first mode of the first sphere
solution, context = cluster.record(incident, rtol=1e-11, restart=30)
assert isinstance(context, IterativeContext)
assert all(c.residual_norm <= 1e-11 * c.rhs_norm for c in solution.convergence)

# Gradient of the squared norm of the scattered coefficients.
gradient = context.pullback(2 * solution.coefficients)
assert isinstance(gradient, Gradient)
fields = ("k0", "radii", "epsilon", "positions", "incident", "convergence")
assert Gradient._fields == fields
assert gradient.positions.shape == (2, 3)

context.pullback(g) takes the gradient g of a real loss with respect to the solution and returns a Gradient with one entry per input of SphereCluster and record, in their order, followed by the convergence of the adjoint solves. An adjoint solve is the linear system with the conjugate transpose matrix that carries the gradient back to the inputs. Differentiation defines how complex gradients pair with their values. A context allows one pullback; record again for a second.

The gradients hold the incident coefficients fixed while the geometry changes. If the incident field depends on the geometry, apply its own pullback to gradient.incident and add the result.

The dense factor for coefficient columns

diff.sphere_cluster_factor builds the dense coupling matrix of the same spheres and factors it once. Every solve reuses that factorization:

import numpy as np
from treams_rs import diff
from treams_rs.iterative import SphereCluster

lmax, k0 = 1, 1.3
radii = np.array([0.2, 0.25])
epsilon = np.array([2.4, 3.1 + 0.02j])
positions = np.array([[0.0, 0.0, 0.0], [1.1, 0.2, 0.0]])
incident = np.ones((12, 2), dtype=complex)

factor = diff.sphere_cluster_factor(lmax, k0, radii, epsilon, positions)
selected = factor.solve(incident)
other = factor.solve(incident * (0.3 + 0.2j))
np.testing.assert_allclose(other, selected * (0.3 + 0.2j), rtol=1e-12)

matrix_free = SphereCluster(lmax, k0, radii, epsilon, positions)
solution = matrix_free.solve(incident, rtol=1e-11)
np.testing.assert_allclose(solution.coefficients, selected, rtol=1e-9, atol=1e-13)

factor.record(incident) returns the solution and an IlluminateContext, whose pullback gives the gradients with respect to the T-matrix blocks of the spheres, the coupling matrix and the incident coefficients. Chain them with the pullbacks of diff.sphere and diff.expansion for gradients with respect to radii, permittivities and positions.

Convergence

The solver applies A = I - T C to vectors, where T holds the Mie blocks and C the translations between spheres, and solves A X = T B with GMRES. GMRES is an iterative solver that needs only products of A with vectors. It builds its approximation from at most restart stored vectors (the Krylov vectors), then starts again from the current solution. After GMRES stops, the solver computes the residual T B - A X of every column, which is zero for an exact solution, and accepts the column only if

||residual|| <= max(atol, rtol * ||rhs||)

The defaults are rtol = 1e-10, atol = 0, restart = 30 and max_iterations = 300 (also available as constants such as iterative.DEFAULT_RTOL). Forward and reverse derivatives use only rtol; atol does not apply, since it would erase sufficiently small directions or loss gradients. If the primal solve used rtol=0, derivatives use the default 1e-10 instead. They keep the same restart and iteration limits, and their accuracy still depends on the solver tolerance.

A solve that reaches max_iterations, breaks down because GMRES cannot extend its stored vectors, or produces a non-finite residual raises ValueError. Each Convergence(iterations, residual_norm, rhs_norm) reports one column.

A small residual does not bound the error of an ill-conditioned system: near resonances, tighten rtol and compare with a dense solve on a smaller cluster.

Memory and time

For N spheres, M modes per sphere, P incident columns and restart R, the storage for the Krylov vectors and the solution grows as O(N M (B R + P) + B R²), where B = min(P, 8). Up to eight columns share each pair translation while retaining independent Krylov spaces, restart decisions and residual checks. Larger inputs use successive batches. The forward operator applies translations directly to these columns, without storing dense pair blocks; the physical pullback still uses a temporary M × M pair cotangent per active worker. The angular translation plan is computed once per lmax and shared by every pair, so it does not grow with N. Its terms grow as M² lmax instead, about 1.1 GB at lmax = 30, and raise MemoryError when refused.

The solver recomputes the pair translations in every iteration and keeps no Krylov vectors for the adjoint solve. It trades time for memory: a reused dense factor computes another response in milliseconds, while the matrix-free solver runs GMRES again. Measure both for your geometry.

The dense diff.sphere_cluster forward peaks at about four complex NM × NM buffers, 64 (NM)² bytes: the coupling, the LU factors, the solution and its NumPy copy. These four allocations raise MemoryError when refused. Other allocations, including gradient buffers and matrix products, can still abort when refused. On Linux, memory overcommit can grant a reservation before the out-of-memory killer later ends the process as its pages are written.